mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31341cc7f1 | |||
| aaa2dbe45e | |||
| b7765ba3f7 | |||
| b89f2e51e4 | |||
| e09f93aa75 | |||
| 9f529b105a | |||
| 89e3d2a867 | |||
| a7b9a4f291 | |||
| 88cd16ae21 | |||
| a8a3e9e604 | |||
| 0061bcc0b0 | |||
| 9c9fa780b0 | |||
| 569ac16163 | |||
| 46f7738f41 | |||
| 3f3669dd34 | |||
| cd65645eea | |||
| 8e88a7a277 | |||
| b393d52439 | |||
| faeec48365 |
+1
-1
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.56-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.57-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
+3
-3
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.56-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.57-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -122,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -30,6 +30,20 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
|
||||
|
||||
## Pricing
|
||||
|
||||
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
|
||||
Pricing follows official API provider rates. Below are the current pricing details for OpenHands models:
|
||||
|
||||
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: \$0.4 per million input tokens and \$1.6 per million output tokens.
|
||||
| Model | Input Cost (per 1M tokens) | Cached Input Cost (per 1M tokens) | Output Cost (per 1M tokens) | Max Input Tokens | Max Output Tokens |
|
||||
|-------|----------------------------|-----------------------------------|------------------------------|------------------|-------------------|
|
||||
| claude-opus-4-20250514 | $15.00 | $1.50 | $75.00 | 200,000 | 32,000 |
|
||||
| claude-sonnet-4-20250514 | $3.00 | $0.30 | $15.00 | 200,000 | 64,000 |
|
||||
| devstral-medium-2507 | $0.40 | N/A | $2.00 | 128,000 | 128,000 |
|
||||
| devstral-small-2505 | $0.10 | N/A | $0.30 | 128,000 | 128,000 |
|
||||
| devstral-small-2507 | $0.10 | N/A | $0.30 | 128,000 | 128,000 |
|
||||
| gemini-2.5-pro | $1.25 | $0.31 | $10.00 | 1,048,576 | 65,535 |
|
||||
| gpt-5-2025-08-07 | $1.25 | $0.125 | $10.00 | 400,000 | 128,000 |
|
||||
| gpt-5-mini-2025-08-07 | $0.25 | $0.025 | $2.00 | 400,000 | 128,000 |
|
||||
| o3 | $2.00 | $0.50 | $8.00 | 200,000 | 100,000 |
|
||||
| o4-mini | $1.10 | $0.28 | $4.40 | 200,000 | 100,000 |
|
||||
| qwen3-coder-480b | $0.40 | N/A | $1.60 | N/A | N/A |
|
||||
|
||||
**Note:** Cached input tokens are charged at a reduced rate when the same content is reused across requests. Models that don't support prompt caching show "N/A" for cached input cost.
|
||||
|
||||
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
+16
-2
@@ -7,14 +7,28 @@ LABEL com.datadoghq.tags.service="deploy"
|
||||
LABEL com.datadoghq.tags.env="${DD_ENV}"
|
||||
|
||||
# Install Node.js v20+ and npm (which includes npx)
|
||||
# Apply security updates to fix CVEs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get install -y jq gettext && \
|
||||
apt-get clean
|
||||
# Apply security updates for packages with available fixes
|
||||
apt-get upgrade -y \
|
||||
libc-bin \
|
||||
libc6 \
|
||||
libgnutls30 \
|
||||
libsqlite3-0 \
|
||||
perl-base && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy
|
||||
# Install Python packages with security fixes
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
|
||||
# Update packages with known CVE fixes
|
||||
pip install --upgrade \
|
||||
"mcp>=1.10.0" \
|
||||
"pillow>=11.3.0"
|
||||
|
||||
WORKDIR /app
|
||||
COPY enterprise .
|
||||
|
||||
@@ -2,7 +2,6 @@ from experiments.constants import (
|
||||
ENABLE_EXPERIMENT_MANAGER,
|
||||
)
|
||||
from experiments.experiment_versions import (
|
||||
handle_claude4_vs_gpt5_experiment,
|
||||
handle_condenser_max_step_experiment,
|
||||
handle_system_prompt_experiment,
|
||||
)
|
||||
@@ -44,9 +43,6 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
return conversation_settings
|
||||
|
||||
# Apply conversation-scoped experiments
|
||||
conversation_settings = handle_claude4_vs_gpt5_experiment(
|
||||
user_id, conversation_id, conversation_settings
|
||||
)
|
||||
conversation_settings = handle_condenser_max_step_experiment(
|
||||
user_id, conversation_id, conversation_settings
|
||||
)
|
||||
|
||||
@@ -357,69 +357,6 @@ describe("ConversationCard", () => {
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show display cost button only when showOptions is true", async () => {
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for context menu to appear
|
||||
const menu = await screen.findByTestId("context-menu");
|
||||
expect(
|
||||
within(menu).queryByTestId("display-cost-button"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
showOptions
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for context menu to appear and check for display cost button
|
||||
const newMenu = await screen.findByTestId("context-menu");
|
||||
within(newMenu).getByTestId("display-cost-button");
|
||||
});
|
||||
|
||||
it("should show metrics modal when clicking the display cost button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showOptions
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const displayCostButton = within(menu).getByTestId("display-cost-button");
|
||||
|
||||
await user.click(displayCostButton);
|
||||
|
||||
// Verify if metrics modal is displayed by checking for the modal content
|
||||
expect(screen.getByTestId("metrics-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the edit or delete options if the handler is not provided", async () => {
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
|
||||
+3
-21
@@ -1,10 +1,9 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientConfig } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { renderWithQueryAndI18n } from "test-utils";
|
||||
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
@@ -18,16 +17,7 @@ describe("ConversationPanel", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const renderConversationPanel = (config?: QueryClientConfig) =>
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const renderConversationPanel = () => renderWithQueryAndI18n(<RouterStub />);
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
@@ -297,15 +287,7 @@ describe("ConversationPanel", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithProviders(<MyRouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithQueryAndI18n(<MyRouterStub />);
|
||||
|
||||
const toggleButton = screen.getByText("Toggle");
|
||||
|
||||
|
||||
-85
@@ -57,11 +57,6 @@ describe("MicroagentManagement", () => {
|
||||
const renderMicroagentManagement = (config?: QueryClientConfig) =>
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
addMicroagentModalVisible: false,
|
||||
updateMicroagentModalVisible: false,
|
||||
@@ -1351,11 +1346,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with modal already visible in Redux state
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: null,
|
||||
addMicroagentModalVisible: true, // Start with modal visible
|
||||
@@ -1646,11 +1636,6 @@ describe("MicroagentManagement", () => {
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) =>
|
||||
renderWithProviders(<MicroagentManagementMain />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: {
|
||||
@@ -1998,11 +1983,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible in Redux state
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2037,11 +2017,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2075,11 +2050,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2118,11 +2088,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2174,11 +2139,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2225,11 +2185,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2279,11 +2234,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible but no microagent data
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: null,
|
||||
addMicroagentModalVisible: false,
|
||||
@@ -2325,11 +2275,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2374,11 +2319,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with update modal visible and microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForUpdate,
|
||||
@@ -2561,11 +2501,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
@@ -2601,11 +2536,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
@@ -2658,11 +2588,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
@@ -2718,11 +2643,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
@@ -2776,11 +2696,6 @@ describe("MicroagentManagement", () => {
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: mockMicroagentForLearn,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { vi, describe, afterEach, it, expect } from "vitest";
|
||||
import { Command, appendInput, appendOutput } from "#/state/command-slice";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
|
||||
const renderTerminal = (commands: Command[] = []) =>
|
||||
renderWithProviders(<Terminal />, {
|
||||
preloadedState: {
|
||||
cmd: {
|
||||
commands,
|
||||
},
|
||||
},
|
||||
});
|
||||
const renderTerminal = (commands: Command[] = []) => {
|
||||
// Set initial commands in Zustand store
|
||||
useCommandStore.setState({ commands });
|
||||
return renderWithProviders(<Terminal />);
|
||||
};
|
||||
|
||||
describe.skip("Terminal", () => {
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
@@ -58,25 +55,25 @@ describe.skip("Terminal", () => {
|
||||
});
|
||||
|
||||
it("should write commands to the terminal", () => {
|
||||
const { store } = renderTerminal();
|
||||
renderTerminal();
|
||||
|
||||
act(() => {
|
||||
store.dispatch(appendInput("echo Hello"));
|
||||
store.dispatch(appendOutput("Hello"));
|
||||
useCommandStore.getState().appendInput("echo Hello");
|
||||
useCommandStore.getState().appendOutput("Hello");
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo Hello");
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
|
||||
|
||||
act(() => {
|
||||
store.dispatch(appendInput("echo World"));
|
||||
useCommandStore.getState().appendInput("echo World");
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(3, "echo World");
|
||||
});
|
||||
|
||||
it("should load and write commands to the terminal", () => {
|
||||
const { store } = renderTerminal([
|
||||
renderTerminal([
|
||||
{ type: "input", content: "echo Hello" },
|
||||
{ type: "output", content: "Hello" },
|
||||
]);
|
||||
@@ -85,17 +82,17 @@ describe.skip("Terminal", () => {
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
|
||||
|
||||
act(() => {
|
||||
store.dispatch(appendInput("echo Hello"));
|
||||
useCommandStore.getState().appendInput("echo Hello");
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(3, "echo Hello");
|
||||
});
|
||||
|
||||
it("should end the line with a dollar sign after writing a command", () => {
|
||||
const { store } = renderTerminal();
|
||||
renderTerminal();
|
||||
|
||||
act(() => {
|
||||
store.dispatch(appendInput("echo Hello"));
|
||||
useCommandStore.getState().appendInput("echo Hello");
|
||||
});
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenCalledWith("echo Hello");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach } from "node:test";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
import { Command } from "#/state/command-slice";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
@@ -19,10 +19,10 @@ interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
function TestTerminalComponent({
|
||||
commands,
|
||||
}: TestTerminalComponentProps) {
|
||||
const ref = useTerminal({ commands });
|
||||
function TestTerminalComponent({ commands }: TestTerminalComponentProps) {
|
||||
// Set commands in Zustand store
|
||||
useCommandStore.setState({ commands });
|
||||
const ref = useTerminal();
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ describe("useTerminal", () => {
|
||||
renderWithProviders(<TestTerminalComponent commands={[]} />, {
|
||||
preloadedState: {
|
||||
agent: { curAgentState: AgentState.RUNNING },
|
||||
cmd: { commands: [] },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -74,7 +73,6 @@ describe("useTerminal", () => {
|
||||
renderWithProviders(<TestTerminalComponent commands={commands} />, {
|
||||
preloadedState: {
|
||||
agent: { curAgentState: AgentState.RUNNING },
|
||||
cmd: { commands },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,17 +92,11 @@ describe("useTerminal", () => {
|
||||
{ content: secret, type: "output" },
|
||||
];
|
||||
|
||||
renderWithProviders(
|
||||
<TestTerminalComponent
|
||||
commands={commands}
|
||||
/>,
|
||||
{
|
||||
preloadedState: {
|
||||
agent: { curAgentState: AgentState.RUNNING },
|
||||
cmd: { commands },
|
||||
},
|
||||
renderWithProviders(<TestTerminalComponent commands={commands} />, {
|
||||
preloadedState: {
|
||||
agent: { curAgentState: AgentState.RUNNING },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// This test is no longer relevant as secrets filtering has been removed
|
||||
});
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import store from "../src/store";
|
||||
import {
|
||||
setInitialPrompt,
|
||||
clearInitialPrompt,
|
||||
} from "../src/state/initial-query-slice";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useInitialQueryStore } from "../src/stores/initial-query-store";
|
||||
|
||||
describe("Initial Query Behavior", () => {
|
||||
it("should clear initial query when clearInitialPrompt is dispatched", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the store before each test
|
||||
useInitialQueryStore.getState().reset();
|
||||
});
|
||||
|
||||
it("should clear initial query when clearInitialPrompt is called", () => {
|
||||
const { setInitialPrompt, clearInitialPrompt, initialPrompt } =
|
||||
useInitialQueryStore.getState();
|
||||
|
||||
// Set up initial query in the store
|
||||
store.dispatch(setInitialPrompt("test query"));
|
||||
expect(store.getState().initialQuery.initialPrompt).toBe("test query");
|
||||
setInitialPrompt("test query");
|
||||
expect(useInitialQueryStore.getState().initialPrompt).toBe("test query");
|
||||
|
||||
// Clear the initial query
|
||||
store.dispatch(clearInitialPrompt());
|
||||
clearInitialPrompt();
|
||||
|
||||
// Verify initial query is cleared
|
||||
expect(store.getState().initialQuery.initialPrompt).toBeNull();
|
||||
expect(useInitialQueryStore.getState().initialPrompt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,14 +13,26 @@ vi.mock("#/store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/command-slice", () => ({
|
||||
appendInput: mockAppendInput,
|
||||
vi.mock("#/state/command-store", () => ({
|
||||
useCommandStore: {
|
||||
getState: () => ({
|
||||
appendInput: mockAppendInput,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/jupyter-slice", () => ({
|
||||
appendJupyterInput: mockAppendJupyterInput,
|
||||
}));
|
||||
|
||||
vi.mock("#/state/metrics-slice", () => ({
|
||||
setMetrics: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/state/security-analyzer-slice", () => ({
|
||||
appendSecurityAnalyzerInput: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("handleActionMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
@@ -45,7 +57,8 @@ describe("handleActionMessage", () => {
|
||||
handleActionMessage(runAction);
|
||||
|
||||
// Check that appendInput was called with the command
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockAppendInput("ls -la"));
|
||||
expect(mockAppendInput).toHaveBeenCalledWith("ls -la");
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -59,7 +72,8 @@ describe("handleActionMessage", () => {
|
||||
args: {
|
||||
code: "print('Hello from Jupyter!')",
|
||||
},
|
||||
message: "Running Python code interactively: print('Hello from Jupyter!')",
|
||||
message:
|
||||
"Running Python code interactively: print('Hello from Jupyter!')",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -67,7 +81,9 @@ describe("handleActionMessage", () => {
|
||||
handleActionMessage(ipythonAction);
|
||||
|
||||
// Check that appendJupyterInput was called with the code
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockAppendJupyterInput("print('Hello from Jupyter!')"));
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
mockAppendJupyterInput("print('Hello from Jupyter!')"),
|
||||
);
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -89,7 +105,9 @@ describe("handleActionMessage", () => {
|
||||
// Handle the action
|
||||
handleActionMessage(hiddenAction);
|
||||
|
||||
// Check that nothing was dispatched
|
||||
// Check that nothing was dispatched or called
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+33
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.57.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.57.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.3",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
@@ -59,7 +59,8 @@
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"vite": "^7.1.4",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
"ws": "^8.18.2",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.3",
|
||||
@@ -18326,6 +18327,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.57.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -58,7 +58,8 @@
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"vite": "^7.1.4",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
"ws": "^8.18.2",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ScrollProvider } from "#/context/scroll-context";
|
||||
import { useInitialQueryStore } from "#/stores/initial-query-store";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -67,9 +68,7 @@ export function ChatInterface() {
|
||||
"positive" | "negative"
|
||||
>("positive");
|
||||
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
|
||||
const { selectedRepository, replayJson } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
const { selectedRepository, replayJson } = useInitialQueryStore();
|
||||
const params = useParams();
|
||||
const { mutateAsync: uploadFiles } = useUploadFiles();
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { Tools } from "../../controls/tools";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
disabled: boolean;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputActions({
|
||||
conversationStatus,
|
||||
disabled,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onStop,
|
||||
}: ChatInputActionsProps) {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
</div>
|
||||
<AgentStatus
|
||||
handleStop={() => handleStop(onStop)}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { DragOver } from "../drag-over";
|
||||
import { UploadedFiles } from "../uploaded-files";
|
||||
import { ChatInputRow } from "./chat-input-row";
|
||||
import { ChatInputActions } from "./chat-input-actions";
|
||||
|
||||
interface ChatInputContainerProps {
|
||||
chatContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
isDragOver: boolean;
|
||||
disabled: boolean;
|
||||
showButton: boolean;
|
||||
buttonClassName: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
handleSubmit: () => void;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
onDrop: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
onInput: () => void;
|
||||
onPaste: (e: React.ClipboardEvent) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputContainer({
|
||||
chatContainerRef,
|
||||
isDragOver,
|
||||
disabled,
|
||||
showButton,
|
||||
buttonClassName,
|
||||
conversationStatus,
|
||||
chatInputRef,
|
||||
handleFileIconClick,
|
||||
handleSubmit,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onInput,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onStop,
|
||||
}: ChatInputContainerProps) {
|
||||
return (
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
|
||||
onDragOver={(e) => onDragOver(e, disabled)}
|
||||
onDragLeave={(e) => onDragLeave(e, disabled)}
|
||||
onDrop={(e) => onDrop(e, disabled)}
|
||||
>
|
||||
{/* Drag Over UI */}
|
||||
{isDragOver && <DragOver />}
|
||||
|
||||
<UploadedFiles />
|
||||
|
||||
<ChatInputRow
|
||||
chatInputRef={chatInputRef}
|
||||
disabled={disabled}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
|
||||
<ChatInputActions
|
||||
conversationStatus={conversationStatus}
|
||||
disabled={disabled}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ChatInputFieldProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
onInput: () => void;
|
||||
onPaste: (e: React.ClipboardEvent) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputField({
|
||||
chatInputRef,
|
||||
onInput,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: ChatInputFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
|
||||
data-name="Text & caret"
|
||||
>
|
||||
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
|
||||
<div
|
||||
ref={chatInputRef}
|
||||
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
|
||||
contentEditable
|
||||
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
|
||||
data-testid="chat-input"
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ChatInputGripProps {
|
||||
gripRef: React.RefObject<HTMLDivElement | null>;
|
||||
isGripVisible: boolean;
|
||||
handleTopEdgeClick: (e: React.MouseEvent) => void;
|
||||
handleGripMouseDown: (e: React.MouseEvent) => void;
|
||||
handleGripTouchStart: (e: React.TouchEvent) => void;
|
||||
}
|
||||
|
||||
export function ChatInputGrip({
|
||||
gripRef,
|
||||
isGripVisible,
|
||||
handleTopEdgeClick,
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
}: ChatInputGripProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute -top-[12px] left-0 w-full h-6 lg:h-3 z-20 group"
|
||||
id="resize-grip"
|
||||
onClick={handleTopEdgeClick}
|
||||
>
|
||||
{/* Resize Grip - appears on hover of top edge area, when dragging, or when clicked */}
|
||||
<div
|
||||
ref={gripRef}
|
||||
className={cn(
|
||||
"absolute top-[4px] left-0 w-full h-[3px] bg-white cursor-ns-resize z-10 transition-opacity duration-200",
|
||||
isGripVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
onMouseDown={handleGripMouseDown}
|
||||
onTouchStart={handleGripTouchStart}
|
||||
style={{ userSelect: "none" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ChatAddFileButton } from "../chat-add-file-button";
|
||||
import { ChatSendButton } from "../chat-send-button";
|
||||
import { ChatInputField } from "./chat-input-field";
|
||||
|
||||
interface ChatInputRowProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
disabled: boolean;
|
||||
showButton: boolean;
|
||||
buttonClassName: string;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
handleSubmit: () => void;
|
||||
onInput: () => void;
|
||||
onPaste: (e: React.ClipboardEvent) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputRow({
|
||||
chatInputRef,
|
||||
disabled,
|
||||
showButton,
|
||||
buttonClassName,
|
||||
handleFileIconClick,
|
||||
handleSubmit,
|
||||
onInput,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: ChatInputRowProps) {
|
||||
return (
|
||||
<div className="box-border content-stretch flex flex-row items-end justify-between p-0 relative shrink-0 w-full pb-[18px] gap-2">
|
||||
<div className="basis-0 box-border content-stretch flex flex-row gap-4 grow items-end justify-start min-h-px min-w-px p-0 relative shrink-0">
|
||||
<ChatAddFileButton
|
||||
disabled={disabled}
|
||||
handleFileIconClick={() => handleFileIconClick(disabled)}
|
||||
/>
|
||||
|
||||
<ChatInputField
|
||||
chatInputRef={chatInputRef}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
{showButton && (
|
||||
<ChatSendButton
|
||||
buttonClassName={cn(buttonClassName, "translate-y-[3px]")}
|
||||
handleSubmit={handleSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
interface HiddenFileInputProps {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function HiddenFileInput({
|
||||
fileInputRef,
|
||||
onChange,
|
||||
}: HiddenFileInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
accept="*/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={onChange}
|
||||
data-testid="upload-image-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,20 @@
|
||||
import React, { useRef, useCallback, useState, useEffect } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ChatSendButton } from "./chat-send-button";
|
||||
import { ChatAddFileButton } from "./chat-add-file-button";
|
||||
import { cn, isMobileDevice } from "#/utils/utils";
|
||||
import { useAutoResize } from "#/hooks/use-auto-resize";
|
||||
import { DragOver } from "./drag-over";
|
||||
import { UploadedFiles } from "./uploaded-files";
|
||||
import { Tools } from "../controls/tools";
|
||||
import {
|
||||
clearAllFiles,
|
||||
setShouldHideSuggestions,
|
||||
setSubmittedMessage,
|
||||
setMessageToSend,
|
||||
setIsRightPanelShown,
|
||||
} from "#/state/conversation-slice";
|
||||
import { CHAT_INPUT } from "#/utils/constants";
|
||||
import { RootState } from "#/store";
|
||||
import { ServerStatus } from "../controls/server-status";
|
||||
import { AgentStatus } from "../controls/agent-status";
|
||||
import { useChatInputLogic } from "#/hooks/chat/use-chat-input-logic";
|
||||
import { useFileHandling } from "#/hooks/chat/use-file-handling";
|
||||
import { useGripResize } from "#/hooks/chat/use-grip-resize";
|
||||
import { useChatInputEvents } from "#/hooks/chat/use-chat-input-events";
|
||||
import { useChatSubmission } from "#/hooks/chat/use-chat-submission";
|
||||
import { ChatInputGrip } from "./components/chat-input-grip";
|
||||
import { ChatInputContainer } from "./components/chat-input-container";
|
||||
import { HiddenFileInput } from "./components/hidden-file-input";
|
||||
|
||||
export interface CustomChatInputProps {
|
||||
disabled?: boolean;
|
||||
@@ -46,13 +41,9 @@ export function CustomChatInput({
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
}: CustomChatInputProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isGripVisible, setIsGripVisible] = useState(false);
|
||||
|
||||
const { messageToSend, submittedMessage, hasRightPanelToggled } = useSelector(
|
||||
const { submittedMessage } = useSelector(
|
||||
(state: RootState) => state.conversation,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Disable input when conversation is stopped
|
||||
@@ -68,87 +59,55 @@ export function CustomChatInput({
|
||||
dispatch(setSubmittedMessage(null));
|
||||
}, [submittedMessage, disabled, onSubmit, dispatch]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chatInputRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const gripRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Save current input value when drawer state changes
|
||||
useEffect(() => {
|
||||
if (chatInputRef.current) {
|
||||
const currentText = chatInputRef.current?.innerText || "";
|
||||
// Dispatch to save current input value when drawer state changes
|
||||
dispatch(setMessageToSend(currentText));
|
||||
dispatch(setIsRightPanelShown(hasRightPanelToggled));
|
||||
}
|
||||
}, [hasRightPanelToggled, dispatch]);
|
||||
|
||||
// Helper function to check if contentEditable is truly empty
|
||||
const isContentEmpty = useCallback((): boolean => {
|
||||
if (!chatInputRef.current) return true;
|
||||
const text =
|
||||
chatInputRef.current.innerText || chatInputRef.current.textContent || "";
|
||||
return text.trim() === "";
|
||||
}, []);
|
||||
|
||||
// Helper function to properly clear contentEditable for placeholder display
|
||||
const clearEmptyContent = useCallback((): void => {
|
||||
if (chatInputRef.current && isContentEmpty()) {
|
||||
chatInputRef.current.innerHTML = "";
|
||||
chatInputRef.current.textContent = "";
|
||||
}
|
||||
}, [isContentEmpty]);
|
||||
|
||||
// Drag state management callbacks
|
||||
const handleDragStart = useCallback(() => {
|
||||
// Keep grip visible during drag by adding a CSS class
|
||||
if (gripRef.current) {
|
||||
gripRef.current.classList.add("opacity-100");
|
||||
gripRef.current.classList.remove("opacity-0");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
// Restore hover-based visibility
|
||||
if (gripRef.current) {
|
||||
gripRef.current.classList.remove("opacity-100");
|
||||
gripRef.current.classList.add("opacity-0");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle click on top edge area to toggle grip visibility
|
||||
const handleTopEdgeClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsGripVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
// Callback to handle height changes and manage suggestions visibility
|
||||
const handleHeightChange = useCallback(
|
||||
(height: number) => {
|
||||
// Hide suggestions when input height exceeds the threshold
|
||||
const shouldHideChatSuggestions = height > CHAT_INPUT.HEIGHT_THRESHOLD;
|
||||
dispatch(setShouldHideSuggestions(shouldHideChatSuggestions));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Use the auto-resize hook with height change callback
|
||||
// Custom hooks
|
||||
const {
|
||||
chatInputRef,
|
||||
messageToSend,
|
||||
checkIsContentEmpty,
|
||||
clearEmptyContentHandler,
|
||||
} = useChatInputLogic();
|
||||
|
||||
const {
|
||||
fileInputRef,
|
||||
chatContainerRef,
|
||||
isDragOver,
|
||||
handleFileIconClick,
|
||||
handleFileInputChange,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useFileHandling(onFilesPaste);
|
||||
|
||||
const {
|
||||
gripRef,
|
||||
isGripVisible,
|
||||
handleTopEdgeClick,
|
||||
smartResize,
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
increaseHeightForEmptyContent,
|
||||
} = useAutoResize(chatInputRef, {
|
||||
minHeight: 20,
|
||||
maxHeight: 400,
|
||||
onHeightChange: handleHeightChange,
|
||||
onGripDragStart: handleDragStart,
|
||||
onGripDragEnd: handleDragEnd,
|
||||
value: messageToSend ?? undefined,
|
||||
enableManualResize: true,
|
||||
});
|
||||
} = useGripResize(
|
||||
chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
messageToSend,
|
||||
);
|
||||
|
||||
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
|
||||
chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
fileInputRef as React.RefObject<HTMLInputElement | null>,
|
||||
smartResize,
|
||||
onSubmit,
|
||||
);
|
||||
|
||||
const { handleInput, handlePaste, handleKeyDown, handleBlur, handleFocus } =
|
||||
useChatInputEvents(
|
||||
chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
smartResize,
|
||||
increaseHeightForEmptyContent,
|
||||
checkIsContentEmpty,
|
||||
clearEmptyContentHandler,
|
||||
onFocus,
|
||||
onBlur,
|
||||
);
|
||||
|
||||
// Cleanup: reset suggestions visibility when component unmounts
|
||||
useEffect(
|
||||
@@ -159,283 +118,46 @@ export function CustomChatInput({
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Function to add files and notify parent
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
// Call onFilesPaste if provided with the new files
|
||||
if (onFilesPaste && files.length > 0) {
|
||||
onFilesPaste(files);
|
||||
}
|
||||
},
|
||||
[onFilesPaste],
|
||||
);
|
||||
|
||||
// File icon click handler
|
||||
const handleFileIconClick = () => {
|
||||
if (!isDisabled && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
// File input change handler
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
addFiles(files);
|
||||
};
|
||||
|
||||
// Drag and drop event handlers
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
// Only remove drag-over class if we're leaving the container entirely
|
||||
if (!chatContainerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
addFiles(files);
|
||||
};
|
||||
|
||||
// Send button click handler
|
||||
const handleSubmit = () => {
|
||||
const message = chatInputRef.current?.innerText || "";
|
||||
|
||||
if (message.trim()) {
|
||||
onSubmit(message);
|
||||
|
||||
// Clear the input
|
||||
if (chatInputRef.current) {
|
||||
chatInputRef.current.textContent = "";
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
}
|
||||
};
|
||||
|
||||
// Resume agent button click handler
|
||||
const handleResumeAgent = () => {
|
||||
const message = chatInputRef.current?.innerText || "continue";
|
||||
|
||||
onSubmit(message.trim());
|
||||
|
||||
// Clear the input
|
||||
if (chatInputRef.current) {
|
||||
chatInputRef.current.textContent = "";
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
};
|
||||
|
||||
// Handle stop button click
|
||||
const handleStop = () => {
|
||||
if (onStop) {
|
||||
onStop();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input events
|
||||
const handleInput = () => {
|
||||
smartResize();
|
||||
|
||||
// Clear empty content to ensure placeholder shows
|
||||
if (chatInputRef.current) {
|
||||
clearEmptyContent();
|
||||
}
|
||||
|
||||
// Ensure cursor stays visible when content is scrollable
|
||||
if (!chatInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (
|
||||
!range.getBoundingClientRect ||
|
||||
!chatInputRef.current.getBoundingClientRect
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = range.getBoundingClientRect();
|
||||
const inputRect = chatInputRef.current.getBoundingClientRect();
|
||||
|
||||
// If cursor is below the visible area, scroll to show it
|
||||
if (rect.bottom > inputRect.bottom) {
|
||||
chatInputRef.current.scrollTop =
|
||||
chatInputRef.current.scrollHeight - chatInputRef.current.clientHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle paste events to clean up formatting
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get plain text from clipboard
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
|
||||
// Insert plain text
|
||||
document.execCommand("insertText", false, text);
|
||||
|
||||
// Trigger resize
|
||||
setTimeout(smartResize, 0);
|
||||
};
|
||||
|
||||
// Handle key events
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isContentEmpty()) {
|
||||
e.preventDefault();
|
||||
increaseHeightForEmptyContent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Original submit logic - only for desktop without shift key
|
||||
if (!isMobileDevice() && !e.shiftKey && !disabled) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle blur events to ensure placeholder shows when empty
|
||||
const handleBlur = () => {
|
||||
// Clear empty content to ensure placeholder shows
|
||||
if (chatInputRef.current) {
|
||||
clearEmptyContent();
|
||||
}
|
||||
|
||||
// Call the original onBlur callback if provided
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
accept="*/*"
|
||||
style={{ display: "none" }}
|
||||
<HiddenFileInput
|
||||
fileInputRef={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
data-testid="upload-image-input"
|
||||
/>
|
||||
|
||||
{/* Container with grip */}
|
||||
<div className="relative w-full">
|
||||
{/* Top edge hover area - invisible area that triggers grip visibility */}
|
||||
<div
|
||||
className="absolute -top-[12px] left-0 w-full h-6 lg:h-3 z-20 group"
|
||||
id="resize-grip"
|
||||
onClick={handleTopEdgeClick}
|
||||
>
|
||||
{/* Resize Grip - appears on hover of top edge area, when dragging, or when clicked */}
|
||||
<div
|
||||
ref={gripRef}
|
||||
className={cn(
|
||||
"absolute top-[4px] left-0 w-full h-[3px] bg-white cursor-ns-resize z-10 transition-opacity duration-200",
|
||||
isGripVisible
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
onMouseDown={handleGripMouseDown}
|
||||
onTouchStart={handleGripTouchStart}
|
||||
style={{ userSelect: "none" }}
|
||||
/>
|
||||
</div>
|
||||
<ChatInputGrip
|
||||
gripRef={gripRef}
|
||||
isGripVisible={isGripVisible}
|
||||
handleTopEdgeClick={handleTopEdgeClick}
|
||||
handleGripMouseDown={handleGripMouseDown}
|
||||
handleGripTouchStart={handleGripTouchStart}
|
||||
/>
|
||||
|
||||
{/* Chat Input Component */}
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
|
||||
<ChatInputContainer
|
||||
chatContainerRef={chatContainerRef}
|
||||
isDragOver={isDragOver}
|
||||
disabled={isDisabled}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
conversationStatus={conversationStatus}
|
||||
chatInputRef={chatInputRef}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drag Over UI */}
|
||||
{isDragOver && <DragOver />}
|
||||
|
||||
<UploadedFiles />
|
||||
{/* Main Input Row */}
|
||||
<div className="box-border content-stretch flex flex-row items-end justify-between p-0 relative shrink-0 w-full pb-[18px] gap-2">
|
||||
<div className="basis-0 box-border content-stretch flex flex-row gap-4 grow items-end justify-start min-h-px min-w-px p-0 relative shrink-0">
|
||||
<ChatAddFileButton
|
||||
disabled={disabled}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
/>
|
||||
|
||||
{/* Chat Input Area */}
|
||||
<div
|
||||
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
|
||||
data-name="Text & caret"
|
||||
>
|
||||
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
|
||||
<div
|
||||
ref={chatInputRef}
|
||||
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
|
||||
contentEditable
|
||||
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
|
||||
data-testid="chat-input"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
{showButton && (
|
||||
<ChatSendButton
|
||||
buttonClassName={cn(buttonClassName, "translate-y-[3px]")}
|
||||
handleSubmit={handleSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
</div>
|
||||
<AgentStatus
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskTrackingObservation } from "#/types/core/observations";
|
||||
import { TaskListSection } from "./task-tracking/task-list-section";
|
||||
import { ResultSection } from "./task-tracking/result-section";
|
||||
|
||||
interface TaskTrackingObservationContentProps {
|
||||
event: TaskTrackingObservation;
|
||||
@@ -9,101 +9,17 @@ interface TaskTrackingObservationContentProps {
|
||||
export function TaskTrackingObservationContent({
|
||||
event,
|
||||
}: TaskTrackingObservationContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { command, task_list: taskList } = event.extras;
|
||||
const shouldShowTaskList = command === "plan" && taskList.length > 0;
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return "⏳";
|
||||
case "in_progress":
|
||||
return "🔄";
|
||||
case "done":
|
||||
return "✅";
|
||||
default:
|
||||
return "❓";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClassName = (status: string) => {
|
||||
if (status === "done") {
|
||||
return "bg-green-800 text-green-200";
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return "bg-yellow-800 text-yellow-200";
|
||||
}
|
||||
return "bg-gray-700 text-gray-300";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Task List section - only show for 'plan' command */}
|
||||
{shouldShowTaskList && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-300">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
|
||||
{taskList.length === 1 ? "item" : "items"})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<div className="space-y-3">
|
||||
{taskList.map((task, index) => (
|
||||
<div key={task.id} className="border-l-2 border-gray-600 pl-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">
|
||||
{getStatusIcon(task.status)}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm text-gray-400">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded uppercase font-semibold ${getStatusClassName(
|
||||
task.status,
|
||||
)}`}
|
||||
>
|
||||
{task.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-white mb-1">
|
||||
{task.title}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-400 mb-1">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
|
||||
</p>
|
||||
{task.notes && (
|
||||
<p className="text-sm text-gray-300 italic">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}:{" "}
|
||||
{task.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
|
||||
|
||||
{/* Result message - only show if there's meaningful content */}
|
||||
{event.content && event.content.trim() && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-300">
|
||||
{t("TASK_TRACKING_OBSERVATION$RESULT")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
{event.content.trim()}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<ResultSection content={event.content} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface ResultSectionProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ResultSection({ content }: ResultSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.H3>{t("TASK_TRACKING_OBSERVATION$RESULT")}</Typography.H3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
|
||||
<pre className="whitespace-pre-wrap text-sm">{content.trim()}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getStatusClassName } from "#/utils/utils";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded uppercase font-semibold ${getStatusClassName(
|
||||
status,
|
||||
)}`}
|
||||
>
|
||||
{status.replace("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getStatusIcon } from "#/utils/utils";
|
||||
|
||||
interface StatusIconProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function StatusIcon({ status }: StatusIconProps) {
|
||||
return <span className="text-lg">{getStatusIcon(status)}</span>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
interface TaskItemProps {
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
};
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function TaskItem({ task, index }: TaskItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-gray-600 pl-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={task.status} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Typography.Text className="text-sm text-gray-400">
|
||||
{index + 1}.
|
||||
</Typography.Text>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<h4 className="font-medium text-white mb-1">{task.title}</h4>
|
||||
<Typography.Text className="text-xs text-gray-400 mb-1">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
|
||||
</Typography.Text>
|
||||
{task.notes && (
|
||||
<Typography.Text className="text-sm text-gray-300 italic">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}: {task.notes}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskItem } from "./task-item";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface TaskListSectionProps {
|
||||
taskList: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function TaskListSection({ taskList }: TaskListSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.H3>
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
|
||||
{taskList.length === 1 ? "item" : "items"})
|
||||
</Typography.H3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<div className="space-y-3">
|
||||
{taskList.map((task, index) => (
|
||||
<TaskItem key={task.id} task={task} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Utility functions for chat input component
|
||||
*/
|
||||
/* eslint-disable no-param-reassign */
|
||||
/**
|
||||
* Check if contentEditable element is truly empty
|
||||
*/
|
||||
export const isContentEmpty = (element: HTMLDivElement | null): boolean => {
|
||||
if (!element) {
|
||||
return true;
|
||||
}
|
||||
const text = element.innerText || element.textContent || "";
|
||||
return text.trim() === "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear empty content from contentEditable element for placeholder display
|
||||
*/
|
||||
export const clearEmptyContent = (element: HTMLDivElement | null): void => {
|
||||
if (element && isContentEmpty(element)) {
|
||||
element.innerHTML = "";
|
||||
element.textContent = "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get text content from contentEditable element
|
||||
*/
|
||||
export const getTextContent = (element: HTMLDivElement | null): string =>
|
||||
element?.innerText || "";
|
||||
|
||||
/**
|
||||
* Clear text content from contentEditable element
|
||||
*/
|
||||
export const clearTextContent = (element: HTMLDivElement | null): void => {
|
||||
if (element) {
|
||||
element.textContent = "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear file input value
|
||||
*/
|
||||
export const clearFileInput = (element: HTMLInputElement | null): void => {
|
||||
if (element) {
|
||||
element.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure cursor stays visible when content is scrollable
|
||||
*/
|
||||
export const ensureCursorVisible = (element: HTMLDivElement | null): void => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.getBoundingClientRect || !element.getBoundingClientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = range.getBoundingClientRect();
|
||||
const inputRect = element.getBoundingClientRect();
|
||||
|
||||
// If cursor is below the visible area, scroll to show it
|
||||
if (rect.bottom > inputRect.bottom) {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useEffect } from "react";
|
||||
import { RootState } from "#/store";
|
||||
import { useStatusStore } from "#/state/status-store";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { getStatusCode } from "#/utils/status";
|
||||
@@ -30,7 +31,7 @@ export function AgentStatus({
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const { curStatusMessage } = useSelector((state: RootState) => state.status);
|
||||
const { curStatusMessage } = useStatusStore();
|
||||
const { webSocketStatus } = useWsClient();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import EllipsisIcon from "#/icons/ellipsis.svg?react";
|
||||
|
||||
interface ConversationCardActionsProps {
|
||||
contextMenuOpen: boolean;
|
||||
onContextMenuToggle: (isOpen: boolean) => void;
|
||||
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationId?: string;
|
||||
showOptions?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationCardActions({
|
||||
contextMenuOpen,
|
||||
onContextMenuToggle,
|
||||
onDelete,
|
||||
onStop,
|
||||
onEdit,
|
||||
onDownloadViaVSCode,
|
||||
conversationStatus,
|
||||
conversationId,
|
||||
showOptions,
|
||||
}: ConversationCardActionsProps) {
|
||||
return (
|
||||
<div className="group">
|
||||
<button
|
||||
data-testid="ellipsis-button"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onContextMenuToggle(!contextMenuOpen);
|
||||
}}
|
||||
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-end"
|
||||
>
|
||||
<EllipsisIcon />
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
// Show on hover (desktop) or when explicitly opened (click/touch)
|
||||
"relative opacity-0 invisible group-hover:opacity-100 group-hover:visible",
|
||||
// Override hover styles when explicitly opened via click
|
||||
contextMenuOpen && "opacity-100 visible",
|
||||
)}
|
||||
>
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => onContextMenuToggle(false)}
|
||||
onDelete={onDelete}
|
||||
onStop={conversationStatus !== "STOPPED" ? onStop : undefined}
|
||||
onEdit={onEdit}
|
||||
onDownloadViaVSCode={
|
||||
conversationId && showOptions ? onDownloadViaVSCode : undefined
|
||||
}
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { NoRepository } from "./no-repository";
|
||||
|
||||
interface ConversationCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
}
|
||||
|
||||
export function ConversationCardFooter({
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
createdAt,
|
||||
}: ConversationCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-row justify-between items-center mt-1")}>
|
||||
{selectedRepository?.selected_repository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
<NoRepository />
|
||||
)}
|
||||
{(createdAt ?? lastUpdatedAt) && (
|
||||
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
|
||||
<time>
|
||||
{`${formatTimeDelta(new Date(lastUpdatedAt ?? createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ConversationCardTitle } from "./conversation-card-title";
|
||||
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
|
||||
import { ConversationStatusBadges } from "./conversation-status-badges";
|
||||
|
||||
interface ConversationCardHeaderProps {
|
||||
title: string;
|
||||
titleMode: "view" | "edit";
|
||||
onTitleSave: (title: string) => void;
|
||||
conversationStatus?: ConversationStatus;
|
||||
}
|
||||
|
||||
export function ConversationCardHeader({
|
||||
title,
|
||||
titleMode,
|
||||
onTitleSave,
|
||||
conversationStatus,
|
||||
}: ConversationCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
{conversationStatus && (
|
||||
<div className="flex items-center">
|
||||
<ConversationStatusIndicator
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ConversationCardTitle
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onSave={onTitleSave}
|
||||
/>
|
||||
{/* Status Badges */}
|
||||
{conversationStatus && (
|
||||
<ConversationStatusBadges conversationStatus={conversationStatus} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-267
@@ -1,28 +1,13 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import { SystemMessageModal } from "../system-message-modal";
|
||||
import { MicroagentsModal } from "../microagents-modal";
|
||||
import { BudgetDisplay } from "../budget-display";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BaseModal } from "../../../shared/modals/base-modal/base-modal";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { isSystemMessage } from "#/types/core/guards";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import EllipsisIcon from "#/icons/ellipsis.svg?react";
|
||||
import { ConversationCardTitle } from "./conversation-card-title";
|
||||
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
|
||||
import { ConversationStatusBadges } from "./conversation-status-badges";
|
||||
import { NoRepository } from "./no-repository";
|
||||
import { ConversationCardHeader } from "./conversation-card-header";
|
||||
import { ConversationCardActions } from "./conversation-card-actions";
|
||||
import { ConversationCardFooter } from "./conversation-card-footer";
|
||||
|
||||
interface ConversationCardProps {
|
||||
onClick?: () => void;
|
||||
@@ -57,18 +42,7 @@ export function ConversationCard({
|
||||
contextMenuOpen = false,
|
||||
onContextMenuToggle,
|
||||
}: ConversationCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { parsedEvents } = useWsClient();
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
|
||||
const [microagentsModalVisible, setMicroagentsModalVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const systemMessage = parsedEvents.find(isSystemMessage);
|
||||
|
||||
// Subscribe to metrics data from Redux store
|
||||
const metrics = useSelector((state: RootState) => state.metrics);
|
||||
|
||||
const onTitleSave = (newTitle: string) => {
|
||||
if (newTitle !== "" && newTitle !== title) {
|
||||
@@ -124,250 +98,47 @@ export function ConversationCard({
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setMetricsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleShowAgentTools = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setSystemModalVisible(true);
|
||||
};
|
||||
|
||||
const handleShowMicroagents = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setMicroagentsModalVisible(true);
|
||||
};
|
||||
|
||||
const hasContextMenu = !!(onDelete || onChangeTitle || showOptions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
data-context-menu-open={contextMenuOpen.toString()}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"data-[context-menu-open=false]:hover:bg-[#454545]",
|
||||
conversationStatus === "ARCHIVED" && "opacity-60",
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
data-context-menu-open={contextMenuOpen.toString()}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"data-[context-menu-open=false]:hover:bg-[#454545]",
|
||||
conversationStatus === "ARCHIVED" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<ConversationCardHeader
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onTitleSave={onTitleSave}
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
|
||||
{hasContextMenu && (
|
||||
<ConversationCardActions
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuToggle={onContextMenuToggle || (() => {})}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onStop={onStop && handleStop}
|
||||
onEdit={onChangeTitle && handleEdit}
|
||||
onDownloadViaVSCode={handleDownloadViaVSCode}
|
||||
conversationStatus={conversationStatus}
|
||||
conversationId={conversationId}
|
||||
showOptions={showOptions}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
{conversationStatus && (
|
||||
<div className="flex items-center">
|
||||
<ConversationStatusIndicator
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ConversationCardTitle
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onSave={onTitleSave}
|
||||
/>
|
||||
{/* Status Badges */}
|
||||
{conversationStatus && (
|
||||
<ConversationStatusBadges
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasContextMenu && (
|
||||
<div className="group">
|
||||
<button
|
||||
data-testid="ellipsis-button"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onContextMenuToggle?.(!contextMenuOpen);
|
||||
}}
|
||||
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-end"
|
||||
>
|
||||
<EllipsisIcon />
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
// Show on hover (desktop) or when explicitly opened (click/touch)
|
||||
"relative opacity-0 invisible group-hover:opacity-100 group-hover:visible",
|
||||
// Override hover styles when explicitly opened via click
|
||||
contextMenuOpen && "opacity-100 visible",
|
||||
)}
|
||||
>
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => onContextMenuToggle?.(false)}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onStop={
|
||||
conversationStatus !== "STOPPED"
|
||||
? onStop && handleStop
|
||||
: undefined
|
||||
}
|
||||
onEdit={onChangeTitle && handleEdit}
|
||||
onDownloadViaVSCode={
|
||||
conversationId && showOptions
|
||||
? handleDownloadViaVSCode
|
||||
: undefined
|
||||
}
|
||||
onDisplayCost={showOptions ? handleDisplayCost : undefined}
|
||||
onShowAgentTools={
|
||||
showOptions && systemMessage
|
||||
? handleShowAgentTools
|
||||
: undefined
|
||||
}
|
||||
onShowMicroagents={
|
||||
showOptions && conversationId
|
||||
? handleShowMicroagents
|
||||
: undefined
|
||||
}
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-row justify-between items-center mt-1")}>
|
||||
{selectedRepository?.selected_repository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
<NoRepository />
|
||||
)}
|
||||
{(createdAt ?? lastUpdatedAt) && (
|
||||
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
|
||||
<time>
|
||||
{`${formatTimeDelta(new Date(lastUpdatedAt ?? createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseModal
|
||||
isOpen={metricsModalVisible}
|
||||
onOpenChange={setMetricsModalVisible}
|
||||
title={t(I18nKey.CONVERSATION$METRICS_INFO)}
|
||||
testID="metrics-modal"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{(metrics?.cost !== null || metrics?.usage !== null) && (
|
||||
<div className="rounded-md p-3">
|
||||
<div className="grid gap-3">
|
||||
{metrics?.cost !== null && (
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span className="text-lg font-semibold">
|
||||
{t(I18nKey.CONVERSATION$TOTAL_COST)}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
${metrics.cost.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<BudgetDisplay
|
||||
cost={metrics?.cost ?? null}
|
||||
maxBudgetPerTask={metrics?.max_budget_per_task ?? null}
|
||||
/>
|
||||
|
||||
{metrics?.usage !== null && (
|
||||
<>
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span>{t(I18nKey.CONVERSATION$INPUT)}</span>
|
||||
<span className="font-semibold">
|
||||
{metrics.usage.prompt_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_HIT)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_read_tokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_write_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<span>{t(I18nKey.CONVERSATION$OUTPUT)}</span>
|
||||
<span className="font-semibold">
|
||||
{metrics.usage.completion_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<span className="font-semibold">
|
||||
{t(I18nKey.CONVERSATION$TOTAL)}
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{(
|
||||
metrics.usage.prompt_tokens +
|
||||
metrics.usage.completion_tokens
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">
|
||||
{t(I18nKey.CONVERSATION$CONTEXT_WINDOW)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-neutral-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(100, (metrics.usage.per_turn_token / metrics.usage.context_window) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-neutral-400">
|
||||
{metrics.usage.per_turn_token.toLocaleString()} /{" "}
|
||||
{metrics.usage.context_window.toLocaleString()} (
|
||||
{(
|
||||
(metrics.usage.per_turn_token /
|
||||
metrics.usage.context_window) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
% {t(I18nKey.CONVERSATION$USED)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!metrics?.cost && !metrics?.usage && (
|
||||
<div className="rounded-md p-4 text-center">
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_METRICS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
||||
<SystemMessageModal
|
||||
isOpen={systemModalVisible}
|
||||
onClose={() => setSystemModalVisible(false)}
|
||||
systemMessage={systemMessage ? systemMessage.args : null}
|
||||
<ConversationCardFooter
|
||||
selectedRepository={selectedRepository}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
createdAt={createdAt}
|
||||
/>
|
||||
|
||||
{microagentsModalVisible && (
|
||||
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useWindowSize } from "@uidotdev/usehooks";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { ChatInterface } from "../chat/chat-interface";
|
||||
import { ConversationTabContent } from "./conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
interface ChatInterfaceWrapperProps {
|
||||
isRightPanelShown: boolean;
|
||||
}
|
||||
|
||||
export function ChatInterfaceWrapper({
|
||||
isRightPanelShown,
|
||||
}: ChatInterfaceWrapperProps) {
|
||||
if (!isRightPanelShown) {
|
||||
return (
|
||||
<div className="flex justify-center w-full h-full">
|
||||
<div className="w-full max-w-[768px]">
|
||||
<ChatInterface />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ChatInterface />;
|
||||
}
|
||||
|
||||
export function ConversationMain() {
|
||||
const { width } = useWindowSize();
|
||||
const isRightPanelShown = useSelector(
|
||||
(state: RootState) => state.conversation.isRightPanelShown,
|
||||
);
|
||||
|
||||
if (width && width <= 1024) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 overflow-auto w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden w-full bg-base min-h-[600px]",
|
||||
!isRightPanelShown && "h-full",
|
||||
)}
|
||||
>
|
||||
<ChatInterface />
|
||||
</div>
|
||||
{isRightPanelShown && (
|
||||
<div className="h-full w-full min-h-[494px] flex flex-col gap-3">
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRightPanelShown) {
|
||||
return (
|
||||
<PanelGroup
|
||||
direction="horizontal"
|
||||
className="grow h-full min-h-0 min-w-0"
|
||||
autoSaveId="react-resizable-panels:layout"
|
||||
>
|
||||
<Panel minSize={30} maxSize={80} className="overflow-hidden bg-base">
|
||||
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
|
||||
</Panel>
|
||||
<PanelResizeHandle className="cursor-ew-resize" />
|
||||
<Panel
|
||||
minSize={20}
|
||||
maxSize={70}
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col flex-1 gap-3">
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 overflow-auto w-full h-full">
|
||||
<div className="overflow-hidden w-full h-full bg-base">
|
||||
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { ChatInterface } from "../../chat/chat-interface";
|
||||
|
||||
interface ChatInterfaceWrapperProps {
|
||||
isRightPanelShown: boolean;
|
||||
}
|
||||
|
||||
export function ChatInterfaceWrapper({
|
||||
isRightPanelShown,
|
||||
}: ChatInterfaceWrapperProps) {
|
||||
if (!isRightPanelShown) {
|
||||
return (
|
||||
<div className="flex justify-center w-full h-full">
|
||||
<div className="w-full max-w-[768px]">
|
||||
<ChatInterface />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ChatInterface />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useWindowSize } from "@uidotdev/usehooks";
|
||||
import { RootState } from "#/store";
|
||||
import { MobileLayout } from "./mobile-layout";
|
||||
import { DesktopLayout } from "./desktop-layout";
|
||||
|
||||
export function ConversationMain() {
|
||||
const { width } = useWindowSize();
|
||||
const isRightPanelShown = useSelector(
|
||||
(state: RootState) => state.conversation.isRightPanelShown,
|
||||
);
|
||||
|
||||
if (width && width <= 1024) {
|
||||
return <MobileLayout isRightPanelShown={isRightPanelShown} />;
|
||||
}
|
||||
|
||||
return <DesktopLayout isRightPanelShown={isRightPanelShown} />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
|
||||
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
|
||||
interface DesktopLayoutProps {
|
||||
isRightPanelShown: boolean;
|
||||
}
|
||||
|
||||
export function DesktopLayout({ isRightPanelShown }: DesktopLayoutProps) {
|
||||
return (
|
||||
<PanelGroup
|
||||
direction="horizontal"
|
||||
className="grow h-full min-h-0 min-w-0"
|
||||
autoSaveId="react-resizable-panels:layout"
|
||||
>
|
||||
<Panel minSize={30} maxSize={80} className="overflow-hidden bg-base">
|
||||
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
|
||||
</Panel>
|
||||
{isRightPanelShown && (
|
||||
<>
|
||||
<PanelResizeHandle className="cursor-ew-resize" />
|
||||
<Panel
|
||||
minSize={20}
|
||||
maxSize={70}
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col flex-1 gap-3">
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</PanelGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChatInterface } from "../../chat/chat-interface";
|
||||
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface MobileLayoutProps {
|
||||
isRightPanelShown: boolean;
|
||||
}
|
||||
|
||||
export function MobileLayout({ isRightPanelShown }: MobileLayoutProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 overflow-auto w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden w-full bg-base min-h-[600px]",
|
||||
!isRightPanelShown && "h-full",
|
||||
)}
|
||||
>
|
||||
<ChatInterface />
|
||||
</div>
|
||||
{isRightPanelShown && (
|
||||
<div className="h-full w-full min-h-[494px] flex flex-col gap-3">
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
@@ -16,6 +16,7 @@ const BrowserTab = lazy(() => import("#/routes/browser-tab"));
|
||||
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
|
||||
const ServedTab = lazy(() => import("#/routes/served-tab"));
|
||||
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
const TasksTab = lazy(() => import("#/routes/tasks-tab"));
|
||||
|
||||
export function ConversationTabContent() {
|
||||
const selectedTab = useSelector(
|
||||
@@ -34,6 +35,7 @@ export function ConversationTabContent() {
|
||||
const isServedActive = selectedTab === "served";
|
||||
const isVSCodeActive = selectedTab === "vscode";
|
||||
const isTerminalActive = selectedTab === "terminal";
|
||||
const isTasksActive = selectedTab === "tasks";
|
||||
|
||||
// Define tab configurations
|
||||
const tabs = [
|
||||
@@ -55,6 +57,7 @@ export function ConversationTabContent() {
|
||||
component: Terminal,
|
||||
isActive: isTerminalActive,
|
||||
},
|
||||
{ key: "tasks", component: TasksTab, isActive: isTasksActive },
|
||||
];
|
||||
|
||||
const conversationTabTitle = useMemo(() => {
|
||||
@@ -76,6 +79,9 @@ export function ConversationTabContent() {
|
||||
if (isTerminalActive) {
|
||||
return t(I18nKey.COMMON$TERMINAL);
|
||||
}
|
||||
if (isTasksActive) {
|
||||
return t(I18nKey.COMMON$TASKS);
|
||||
}
|
||||
return "";
|
||||
}, [
|
||||
isEditorActive,
|
||||
@@ -84,6 +90,7 @@ export function ConversationTabContent() {
|
||||
isServedActive,
|
||||
isVSCodeActive,
|
||||
isTerminalActive,
|
||||
isTasksActive,
|
||||
]);
|
||||
|
||||
if (shouldShownAgentLoading) {
|
||||
|
||||
+3
@@ -5,12 +5,14 @@ type ConversationTabNavProps = {
|
||||
icon: ComponentType<{ className: string }>;
|
||||
onClick(): void;
|
||||
isActive?: boolean;
|
||||
"data-testid"?: string;
|
||||
};
|
||||
|
||||
export function ConversationTabNav({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
isActive,
|
||||
"data-testid": dataTestId,
|
||||
}: ConversationTabNavProps) {
|
||||
return (
|
||||
<button
|
||||
@@ -18,6 +20,7 @@ export function ConversationTabNav({
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
data-testid={dataTestId}
|
||||
className={cn(
|
||||
"p-1 rounded-md cursor-pointer",
|
||||
"text-[#9299AA] bg-[#0D0F11]",
|
||||
|
||||
@@ -8,6 +8,7 @@ import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import ListIcon from "#/icons/list.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ConversationTabNav } from "./conversation-tab-nav";
|
||||
import { ChatActionTooltip } from "../../chat/chat-action-tooltip";
|
||||
@@ -130,8 +131,19 @@ export function ConversationTabs() {
|
||||
tooltipContent: t(I18nKey.COMMON$BROWSER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$BROWSER),
|
||||
},
|
||||
{
|
||||
isActive: isTabActive("tasks"),
|
||||
icon: ListIcon,
|
||||
onClick: () => onTabSelected("tasks"),
|
||||
tooltipContent: t(I18nKey.COMMON$TASKS),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$TASKS),
|
||||
},
|
||||
];
|
||||
|
||||
// Debug logging to help troubleshoot tab visibility
|
||||
console.log("ConversationTabs: Rendering", tabs.length, "tabs");
|
||||
console.log("ConversationTabs: Tasks tab config:", tabs[tabs.length - 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -153,6 +165,7 @@ export function ConversationTabs() {
|
||||
icon={icon}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
data-testid={index === tabs.length - 1 ? "tasks-tab" : undefined}
|
||||
/>
|
||||
</ChatActionTooltip>
|
||||
),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { BaseModal } from "../../../shared/modals/base-modal/base-modal";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { CostSection } from "./cost-section";
|
||||
import { UsageSection } from "./usage-section";
|
||||
import { ContextWindowSection } from "./context-window-section";
|
||||
import { EmptyState } from "./empty-state";
|
||||
import useMetricsStore from "#/stores/metrics-store";
|
||||
|
||||
interface MetricsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,7 +14,7 @@ interface MetricsModalProps {
|
||||
|
||||
export function MetricsModal({ isOpen, onOpenChange }: MetricsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const metrics = useSelector((state: RootState) => state.metrics);
|
||||
const metrics = useMetricsStore();
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
|
||||
@@ -7,14 +7,11 @@ import { cn } from "#/utils/utils";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
|
||||
function Terminal() {
|
||||
const { commands } = useSelector((state: RootState) => state.cmd);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const ref = useTerminal({
|
||||
commands,
|
||||
});
|
||||
const ref = useTerminal();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl">
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback } from "react";
|
||||
import { isMobileDevice } from "#/utils/utils";
|
||||
import {
|
||||
ensureCursorVisible,
|
||||
clearEmptyContent,
|
||||
} from "#/components/features/chat/utils/chat-input.utils";
|
||||
|
||||
/**
|
||||
* Hook for handling chat input events
|
||||
*/
|
||||
export const useChatInputEvents = (
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>,
|
||||
smartResize: () => void,
|
||||
increaseHeightForEmptyContent: () => void,
|
||||
checkIsContentEmpty: () => boolean,
|
||||
clearEmptyContentHandler: () => void,
|
||||
onFocus?: () => void,
|
||||
onBlur?: () => void,
|
||||
) => {
|
||||
// Handle input events
|
||||
const handleInput = useCallback(() => {
|
||||
smartResize();
|
||||
|
||||
// Clear empty content to ensure placeholder shows
|
||||
if (chatInputRef.current) {
|
||||
clearEmptyContent(chatInputRef.current);
|
||||
}
|
||||
|
||||
// Ensure cursor stays visible when content is scrollable
|
||||
ensureCursorVisible(chatInputRef.current);
|
||||
}, [smartResize, chatInputRef]);
|
||||
|
||||
// Handle paste events to clean up formatting
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get plain text from clipboard
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
|
||||
// Insert plain text
|
||||
document.execCommand("insertText", false, text);
|
||||
|
||||
// Trigger resize
|
||||
setTimeout(smartResize, 0);
|
||||
},
|
||||
[smartResize],
|
||||
);
|
||||
|
||||
// Handle key events
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, disabled: boolean, handleSubmit: () => void) => {
|
||||
if (e.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkIsContentEmpty()) {
|
||||
e.preventDefault();
|
||||
increaseHeightForEmptyContent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Original submit logic - only for desktop without shift key
|
||||
if (!isMobileDevice() && !e.shiftKey && !disabled) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[checkIsContentEmpty, increaseHeightForEmptyContent],
|
||||
);
|
||||
|
||||
// Handle blur events to ensure placeholder shows when empty
|
||||
const handleBlur = useCallback(() => {
|
||||
// Clear empty content to ensure placeholder shows
|
||||
if (chatInputRef.current) {
|
||||
clearEmptyContent(chatInputRef.current);
|
||||
}
|
||||
|
||||
// Call the original onBlur callback if provided
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
}, [chatInputRef, onBlur]);
|
||||
|
||||
// Handle focus events
|
||||
const handleFocus = useCallback(() => {
|
||||
if (onFocus) {
|
||||
onFocus();
|
||||
}
|
||||
}, [onFocus]);
|
||||
|
||||
return {
|
||||
handleInput,
|
||||
handlePaste,
|
||||
handleKeyDown,
|
||||
handleBlur,
|
||||
handleFocus,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
setMessageToSend,
|
||||
setIsRightPanelShown,
|
||||
} from "#/state/conversation-slice";
|
||||
import { RootState } from "#/store";
|
||||
import {
|
||||
isContentEmpty,
|
||||
clearEmptyContent,
|
||||
getTextContent,
|
||||
} from "#/components/features/chat/utils/chat-input.utils";
|
||||
|
||||
/**
|
||||
* Hook for managing chat input content logic
|
||||
*/
|
||||
export const useChatInputLogic = () => {
|
||||
const chatInputRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { messageToSend, hasRightPanelToggled } = useSelector(
|
||||
(state: RootState) => state.conversation,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Save current input value when drawer state changes
|
||||
useEffect(() => {
|
||||
if (chatInputRef.current) {
|
||||
const currentText = getTextContent(chatInputRef.current);
|
||||
dispatch(setMessageToSend(currentText));
|
||||
dispatch(setIsRightPanelShown(hasRightPanelToggled));
|
||||
}
|
||||
}, [hasRightPanelToggled, dispatch]);
|
||||
|
||||
// Helper function to check if contentEditable is truly empty
|
||||
const checkIsContentEmpty = useCallback(
|
||||
(): boolean => isContentEmpty(chatInputRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper function to properly clear contentEditable for placeholder display
|
||||
const clearEmptyContentHandler = useCallback((): void => {
|
||||
clearEmptyContent(chatInputRef.current);
|
||||
}, []);
|
||||
|
||||
// Get current message text
|
||||
const getCurrentMessage = useCallback(
|
||||
(): string => getTextContent(chatInputRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
chatInputRef,
|
||||
messageToSend,
|
||||
checkIsContentEmpty,
|
||||
clearEmptyContentHandler,
|
||||
getCurrentMessage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
clearTextContent,
|
||||
clearFileInput,
|
||||
} from "#/components/features/chat/utils/chat-input.utils";
|
||||
|
||||
/**
|
||||
* Hook for handling chat message submission
|
||||
*/
|
||||
export const useChatSubmission = (
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>,
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>,
|
||||
smartResize: () => void,
|
||||
onSubmit: (message: string) => void,
|
||||
) => {
|
||||
// Send button click handler
|
||||
const handleSubmit = useCallback(() => {
|
||||
const message = chatInputRef.current?.innerText || "";
|
||||
const trimmedMessage = message.trim();
|
||||
|
||||
if (!trimmedMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(message);
|
||||
|
||||
// Clear the input
|
||||
clearTextContent(chatInputRef.current);
|
||||
clearFileInput(fileInputRef.current);
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
|
||||
|
||||
// Resume agent button click handler
|
||||
const handleResumeAgent = useCallback(() => {
|
||||
const message = chatInputRef.current?.innerText || "continue";
|
||||
|
||||
onSubmit(message.trim());
|
||||
|
||||
// Clear the input
|
||||
clearTextContent(chatInputRef.current);
|
||||
clearFileInput(fileInputRef.current);
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
|
||||
|
||||
// Handle stop button click
|
||||
const handleStop = useCallback((onStop?: () => void) => {
|
||||
if (onStop) {
|
||||
onStop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
handleResumeAgent,
|
||||
handleStop,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useRef, useCallback, useState } from "react";
|
||||
|
||||
interface UseFileHandlingReturn {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
chatContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
isDragOver: boolean;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
handleDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
handleDrop: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling file operations (upload, drag & drop)
|
||||
*/
|
||||
export const useFileHandling = (
|
||||
onFilesPaste?: (files: File[]) => void,
|
||||
): UseFileHandlingReturn => {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
// Function to add files and notify parent
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (onFilesPaste && files.length > 0) {
|
||||
onFilesPaste(files);
|
||||
}
|
||||
},
|
||||
[onFilesPaste],
|
||||
);
|
||||
|
||||
// File icon click handler
|
||||
const handleFileIconClick = useCallback((isDisabled: boolean) => {
|
||||
if (!isDisabled && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// File input change handler
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
addFiles(files);
|
||||
},
|
||||
[addFiles],
|
||||
);
|
||||
|
||||
// Drag and drop event handlers
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, isDisabled: boolean) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent, isDisabled: boolean) => {
|
||||
if (
|
||||
isDisabled ||
|
||||
chatContainerRef.current?.contains(e.relatedTarget as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, isDisabled: boolean) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
addFiles(files);
|
||||
},
|
||||
[addFiles],
|
||||
);
|
||||
|
||||
return {
|
||||
fileInputRef,
|
||||
chatContainerRef,
|
||||
isDragOver,
|
||||
handleFileIconClick,
|
||||
handleFileInputChange,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useAutoResize } from "#/hooks/use-auto-resize";
|
||||
import {
|
||||
IMessageToSend,
|
||||
setShouldHideSuggestions,
|
||||
} from "#/state/conversation-slice";
|
||||
import { CHAT_INPUT } from "#/utils/constants";
|
||||
|
||||
/**
|
||||
* Hook for managing grip resize functionality
|
||||
*/
|
||||
export const useGripResize = (
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>,
|
||||
messageToSend: IMessageToSend | null,
|
||||
) => {
|
||||
const gripRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [isGripVisible, setIsGripVisible] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Drag state management callbacks
|
||||
const handleDragStart = useCallback(() => {
|
||||
// Keep grip visible during drag by adding a CSS class
|
||||
if (gripRef.current) {
|
||||
gripRef.current.classList.add("opacity-100");
|
||||
gripRef.current.classList.remove("opacity-0");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
// Restore hover-based visibility
|
||||
if (gripRef.current) {
|
||||
gripRef.current.classList.remove("opacity-100");
|
||||
gripRef.current.classList.add("opacity-0");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle click on top edge area to toggle grip visibility
|
||||
const handleTopEdgeClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsGripVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Callback to handle height changes and manage suggestions visibility
|
||||
const handleHeightChange = useCallback(
|
||||
(height: number) => {
|
||||
// Hide suggestions when input height exceeds the threshold
|
||||
const shouldHideChatSuggestions = height > CHAT_INPUT.HEIGHT_THRESHOLD;
|
||||
dispatch(setShouldHideSuggestions(shouldHideChatSuggestions));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Use the auto-resize hook with height change callback
|
||||
const {
|
||||
smartResize,
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
increaseHeightForEmptyContent,
|
||||
} = useAutoResize(chatInputRef as React.RefObject<HTMLElement | null>, {
|
||||
minHeight: 20,
|
||||
maxHeight: 400,
|
||||
onHeightChange: handleHeightChange,
|
||||
onGripDragStart: handleDragStart,
|
||||
onGripDragEnd: handleDragEnd,
|
||||
value: messageToSend ?? undefined,
|
||||
enableManualResize: true,
|
||||
});
|
||||
|
||||
return {
|
||||
gripRef,
|
||||
isGripVisible,
|
||||
handleTopEdgeClick,
|
||||
smartResize,
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
increaseHeightForEmptyContent,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
const parseTasksFromMarkdown = (content: string): Task[] => {
|
||||
const tasks: Task[] = [];
|
||||
const lines = content.split("\n");
|
||||
|
||||
let currentTask: Partial<Task> | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// Match task lines like: "1. ✅ Add 'tasks' to ConversationTab type in conversation-slice.tsx"
|
||||
const taskMatch = line.match(/^(\d+)\.\s*([✅🔄⏳])\s*(.+)$/u);
|
||||
if (taskMatch) {
|
||||
// Save previous task if exists
|
||||
if (
|
||||
currentTask &&
|
||||
currentTask.id &&
|
||||
currentTask.title &&
|
||||
currentTask.status
|
||||
) {
|
||||
tasks.push(currentTask as Task);
|
||||
}
|
||||
|
||||
const [, id, statusIcon, title] = taskMatch;
|
||||
let status: Task["status"] = "todo";
|
||||
|
||||
// Determine status from emoji
|
||||
if (statusIcon === "✅") {
|
||||
status = "done";
|
||||
} else if (statusIcon === "🔄") {
|
||||
status = "in_progress";
|
||||
} else if (statusIcon === "⏳") {
|
||||
status = "todo";
|
||||
}
|
||||
|
||||
currentTask = {
|
||||
id,
|
||||
title: title.trim(),
|
||||
status,
|
||||
notes: "",
|
||||
};
|
||||
} else if (currentTask && line.trim() && !line.startsWith("#")) {
|
||||
// This is likely a notes line for the current task
|
||||
if (currentTask.notes) {
|
||||
currentTask.notes += ` ${line.trim()}`;
|
||||
} else {
|
||||
currentTask.notes = line.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last task
|
||||
if (
|
||||
currentTask &&
|
||||
currentTask.id &&
|
||||
currentTask.title &&
|
||||
currentTask.status
|
||||
) {
|
||||
tasks.push(currentTask as Task);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
};
|
||||
|
||||
export const useGetTasks = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["tasks", conversationId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const content = await ConversationService.getFile(
|
||||
conversationId,
|
||||
"TASKS.md",
|
||||
);
|
||||
return parseTasksFromMarkdown(content);
|
||||
} catch (error) {
|
||||
// If TASKS.md doesn't exist, return empty array
|
||||
return [];
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
gcTime: 1000 * 60 * 5, // 5 minutes
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2,10 +2,9 @@ import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { RootState } from "#/store";
|
||||
import useMetricsStore from "#/stores/metrics-store";
|
||||
import { isSystemMessage } from "#/types/core/guards";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
@@ -36,7 +35,7 @@ export function useConversationNameContextMenu({
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: getTrajectory } = useGetTrajectory();
|
||||
const metrics = useSelector((state: RootState) => state.metrics);
|
||||
const metrics = useMetricsStore();
|
||||
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
|
||||
|
||||
@@ -2,26 +2,18 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { Command } from "#/state/command-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { getTerminalCommand } from "#/services/terminal-service";
|
||||
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
/*
|
||||
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
|
||||
The reason for this is that the hook exposes a ref that requires a DOM element to be rendered.
|
||||
*/
|
||||
|
||||
interface UseTerminalConfig {
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
|
||||
commands: [],
|
||||
};
|
||||
|
||||
const renderCommand = (
|
||||
command: Command,
|
||||
terminal: Terminal,
|
||||
@@ -44,11 +36,10 @@ const renderCommand = (
|
||||
// This ensures terminal history is preserved when navigating away and back
|
||||
const persistentLastCommandIndex = { current: 0 };
|
||||
|
||||
export const useTerminal = ({
|
||||
commands,
|
||||
}: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => {
|
||||
export const useTerminal = () => {
|
||||
const { send } = useWsClient();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const commands = useCommandStore((state) => state.commands);
|
||||
const terminal = React.useRef<Terminal | null>(null);
|
||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -890,6 +890,7 @@ export enum I18nKey {
|
||||
COMMON$START_CONVERSATION = "COMMON$START_CONVERSATION",
|
||||
COMMON$STOP_SERVER = "COMMON$STOP_SERVER",
|
||||
COMMON$TERMINAL = "COMMON$TERMINAL",
|
||||
COMMON$TASKS = "COMMON$TASKS",
|
||||
COMMON$UNKNOWN = "COMMON$UNKNOWN",
|
||||
COMMON$USER_SETTINGS = "COMMON$USER_SETTINGS",
|
||||
COMMON$VIEW = "COMMON$VIEW",
|
||||
@@ -910,4 +911,6 @@ export enum I18nKey {
|
||||
COMMON$STOP_RUNTIME = "COMMON$STOP_RUNTIME",
|
||||
COMMON$START_RUNTIME = "COMMON$START_RUNTIME",
|
||||
COMMON$JUPYTER_EMPTY_MESSAGE = "COMMON$JUPYTER_EMPTY_MESSAGE",
|
||||
COMMON$ERROR_LOADING_TASKS = "COMMON$ERROR_LOADING_TASKS",
|
||||
COMMON$NO_TASKS_AVAILABLE = "COMMON$NO_TASKS_AVAILABLE",
|
||||
}
|
||||
|
||||
@@ -14239,6 +14239,22 @@
|
||||
"de": "Terminal",
|
||||
"uk": "Термінал"
|
||||
},
|
||||
"COMMON$TASKS": {
|
||||
"en": "Task List",
|
||||
"ja": "タスクリスト",
|
||||
"zh-CN": "任务列表",
|
||||
"zh-TW": "任務列表",
|
||||
"ko-KR": "작업 목록",
|
||||
"no": "Oppgaveliste",
|
||||
"it": "Elenco attività",
|
||||
"pt": "Lista de tarefas",
|
||||
"es": "Lista de tareas",
|
||||
"ar": "قائمة المهام",
|
||||
"fr": "Liste des tâches",
|
||||
"tr": "Görev Listesi",
|
||||
"de": "Aufgabenliste",
|
||||
"uk": "Список завдань"
|
||||
},
|
||||
"COMMON$UNKNOWN": {
|
||||
"en": "Unknown",
|
||||
"ja": "不明",
|
||||
@@ -14558,5 +14574,37 @@
|
||||
"tr": "Jupyter defteriniz boş. Gösterilecek hücre yok.",
|
||||
"de": "Ihr Jupyter-Notebook ist leer. Keine Zellen zum Anzeigen.",
|
||||
"uk": "Ваш Jupyter-ноутбук порожній. Немає клітинок для відображення."
|
||||
},
|
||||
"COMMON$ERROR_LOADING_TASKS": {
|
||||
"en": "Error loading tasks",
|
||||
"ja": "タスクの読み込み中にエラーが発生しました",
|
||||
"zh-CN": "加载任务时出错",
|
||||
"zh-TW": "載入任務時發生錯誤",
|
||||
"ko-KR": "작업을 불러오는 중 오류가 발생했습니다",
|
||||
"no": "Feil ved lasting av oppgaver",
|
||||
"it": "Errore nel caricamento delle attività",
|
||||
"pt": "Erro ao carregar tarefas",
|
||||
"es": "Error al cargar tareas",
|
||||
"ar": "خطأ في تحميل المهام",
|
||||
"fr": "Erreur lors du chargement des tâches",
|
||||
"tr": "Görevler yüklenirken hata oluştu",
|
||||
"de": "Fehler beim Laden der Aufgaben",
|
||||
"uk": "Помилка завантаження завдань"
|
||||
},
|
||||
"COMMON$NO_TASKS_AVAILABLE": {
|
||||
"en": "No tasks available",
|
||||
"ja": "利用可能なタスクがありません",
|
||||
"zh-CN": "没有可用的任务",
|
||||
"zh-TW": "沒有可用的任務",
|
||||
"ko-KR": "사용 가능한 작업이 없습니다",
|
||||
"no": "Ingen oppgaver tilgjengelig",
|
||||
"it": "Nessuna attività disponibile",
|
||||
"pt": "Nenhuma tarefa disponível",
|
||||
"es": "No hay tareas disponibles",
|
||||
"ar": "لا توجد مهام متاحة",
|
||||
"fr": "Aucune tâche disponible",
|
||||
"tr": "Mevcut görev yok",
|
||||
"de": "Keine Aufgaben verfügbar",
|
||||
"uk": "Немає доступних завдань"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDispatch } from "react-redux";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { clearTerminal } from "#/state/command-slice";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { useEffectOnce } from "#/hooks/use-effect-once";
|
||||
import { clearJupyter } from "#/state/jupyter-slice";
|
||||
import { resetConversationState } from "#/state/conversation-slice";
|
||||
@@ -24,7 +24,7 @@ import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
import { ConversationMain } from "#/components/features/conversation/conversation-main";
|
||||
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
|
||||
import { ConversationName } from "#/components/features/conversation/conversation-name";
|
||||
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
@@ -40,6 +40,7 @@ function AppContent() {
|
||||
const { providers } = useUserProviders();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const clearTerminal = useCommandStore((state) => state.clearTerminal);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch batch feedback data when conversation is loaded
|
||||
@@ -83,14 +84,14 @@ function AppContent() {
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(clearTerminal());
|
||||
clearTerminal();
|
||||
dispatch(clearJupyter());
|
||||
dispatch(resetConversationState());
|
||||
dispatch(setCurrentAgentState(AgentState.LOADING));
|
||||
}, [conversationId]);
|
||||
}, [conversationId, clearTerminal]);
|
||||
|
||||
useEffectOnce(() => {
|
||||
dispatch(clearTerminal());
|
||||
clearTerminal();
|
||||
dispatch(clearJupyter());
|
||||
dispatch(resetConversationState());
|
||||
dispatch(setCurrentAgentState(AgentState.LOADING));
|
||||
|
||||
@@ -526,12 +526,14 @@ function LlmSettingsScreen() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<HelpLink
|
||||
testId="search-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
|
||||
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
|
||||
href="https://tavily.com/"
|
||||
/>
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<HelpLink
|
||||
testId="search-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
|
||||
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
|
||||
href="https://tavily.com/"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { TaskListSection } from "#/components/features/chat/task-tracking/task-list-section";
|
||||
import { useGetTasks } from "#/hooks/query/use-get-tasks";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { RandomTip } from "#/components/features/tips/random-tip";
|
||||
|
||||
function StatusMessage({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksTab() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: tasks,
|
||||
isSuccess,
|
||||
isError,
|
||||
error,
|
||||
isLoading: loadingTasks,
|
||||
} = useGetTasks();
|
||||
|
||||
const [statusMessage, setStatusMessage] = React.useState<string[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const runtimeIsActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!runtimeIsActive) {
|
||||
setStatusMessage([I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME]);
|
||||
} else if (error) {
|
||||
setStatusMessage([I18nKey.COMMON$ERROR_LOADING_TASKS]);
|
||||
} else if (loadingTasks) {
|
||||
setStatusMessage([I18nKey.HOME$LOADING]);
|
||||
} else {
|
||||
setStatusMessage(null);
|
||||
}
|
||||
}, [runtimeIsActive, loadingTasks, error, setStatusMessage]);
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-y-scroll p-4 md:pr-1.5 gap-3 flex flex-col items-center custom-scrollbar-always">
|
||||
{!isSuccess || !tasks || tasks.length === 0 ? (
|
||||
<div className="relative flex h-full w-full items-center">
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2">
|
||||
{statusMessage && (
|
||||
<StatusMessage>
|
||||
{statusMessage.map((msg) => (
|
||||
<span key={msg}>{t(msg)}</span>
|
||||
))}
|
||||
</StatusMessage>
|
||||
)}
|
||||
{!statusMessage && isSuccess && tasks && tasks.length === 0 && (
|
||||
<StatusMessage>
|
||||
<span>{t(I18nKey.COMMON$NO_TASKS_AVAILABLE)}</span>
|
||||
</StatusMessage>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0">
|
||||
{!isError && tasks?.length === 0 && (
|
||||
<div className="max-w-2xl mb-4 text-m bg-tertiary rounded-xl p-4 text-left mx-auto">
|
||||
<RandomTip />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-4xl">
|
||||
<TaskListSection taskList={tasks} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default TasksTab;
|
||||
@@ -3,7 +3,7 @@ import { handleStatusMessage } from "../actions";
|
||||
import { StatusMessage } from "#/types/message";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import store from "#/store";
|
||||
import { setCurStatusMessage } from "#/state/status-slice";
|
||||
import { useStatusStore } from "#/state/status-store";
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -19,12 +19,12 @@ vi.mock("#/store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/status-slice", () => ({
|
||||
setCurStatusMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/state/chat-slice", () => ({
|
||||
addErrorMessage: vi.fn(),
|
||||
vi.mock("#/state/status-store", () => ({
|
||||
useStatusStore: {
|
||||
getState: vi.fn(() => ({
|
||||
setCurStatusMessage: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/error-handler", () => ({
|
||||
@@ -61,7 +61,7 @@ describe("handleStatusMessage", () => {
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should dispatch setCurStatusMessage for info messages without conversation_title", () => {
|
||||
it("should call setCurStatusMessage for info messages without conversation_title", () => {
|
||||
// Create a status message without a conversation title
|
||||
const statusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
@@ -69,19 +69,28 @@ describe("handleStatusMessage", () => {
|
||||
message: "Some info message",
|
||||
};
|
||||
|
||||
const mockSetCurStatusMessage = vi.fn();
|
||||
vi.mocked(useStatusStore.getState).mockReturnValue({
|
||||
setCurStatusMessage: mockSetCurStatusMessage,
|
||||
curStatusMessage: {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
id: "",
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Call the function
|
||||
handleStatusMessage(statusMessage);
|
||||
|
||||
// Verify that store.dispatch was called with setCurStatusMessage
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
setCurStatusMessage(statusMessage),
|
||||
);
|
||||
// Verify that setCurStatusMessage was called with the correct message
|
||||
expect(mockSetCurStatusMessage).toHaveBeenCalledWith(statusMessage);
|
||||
|
||||
// Verify that queryClient.invalidateQueries was not called
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should dispatch addErrorMessage for error messages", () => {
|
||||
it("should call trackError for error messages", () => {
|
||||
// Create an error status message
|
||||
const statusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
@@ -100,6 +109,9 @@ describe("handleStatusMessage", () => {
|
||||
metadata: { msgId: "ERROR_ID" },
|
||||
});
|
||||
|
||||
// Verify that store.dispatch was not called
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that queryClient.invalidateQueries was not called
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
import { appendSecurityAnalyzerInput } from "#/state/security-analyzer-slice";
|
||||
import { setCurStatusMessage } from "#/state/status-slice";
|
||||
import { setMetrics } from "#/state/metrics-slice";
|
||||
import useMetricsStore from "#/stores/metrics-store";
|
||||
import { useStatusStore } from "#/state/status-store";
|
||||
import store from "#/store";
|
||||
import ActionType from "#/types/action-type";
|
||||
import {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
StatusMessage,
|
||||
} from "#/types/message";
|
||||
import { handleObservationMessage } from "./observations";
|
||||
import { appendInput } from "#/state/command-slice";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { appendJupyterInput } from "#/state/jupyter-slice";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
|
||||
@@ -26,11 +26,11 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
max_budget_per_task: message.llm_metrics?.max_budget_per_task ?? null,
|
||||
usage: message.llm_metrics?.accumulated_token_usage ?? null,
|
||||
};
|
||||
store.dispatch(setMetrics(metrics));
|
||||
useMetricsStore.getState().setMetrics(metrics);
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN) {
|
||||
store.dispatch(appendInput(message.args.command));
|
||||
useCommandStore.getState().appendInput(message.args.command);
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN_IPYTHON) {
|
||||
@@ -52,11 +52,9 @@ export function handleStatusMessage(message: StatusMessage) {
|
||||
queryKey: ["user", "conversation", conversationId],
|
||||
});
|
||||
} else if (message.type === "info") {
|
||||
store.dispatch(
|
||||
setCurStatusMessage({
|
||||
...message,
|
||||
}),
|
||||
);
|
||||
useStatusStore.getState().setCurStatusMessage({
|
||||
...message,
|
||||
});
|
||||
} else if (message.type === "error") {
|
||||
trackError({
|
||||
message: message.message,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { setUrl, setScreenshotSrc } from "#/state/browser-slice";
|
||||
import store from "#/store";
|
||||
import { ObservationMessage } from "#/types/message";
|
||||
import { appendOutput } from "#/state/command-slice";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { appendJupyterOutput } from "#/state/jupyter-slice";
|
||||
import ObservationType from "#/types/observation-type";
|
||||
|
||||
@@ -19,7 +19,7 @@ export function handleObservationMessage(message: ObservationMessage) {
|
||||
content = `${head}\r\n\n... (truncated ${message.content.length - 5000} characters) ...\r\n\n${tail}`;
|
||||
}
|
||||
|
||||
store.dispatch(appendOutput(content));
|
||||
useCommandStore.getState().appendOutput(content);
|
||||
break;
|
||||
}
|
||||
case ObservationType.RUN_IPYTHON:
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
export interface FileState {
|
||||
path: string;
|
||||
savedContent: string;
|
||||
unsavedContent: string;
|
||||
}
|
||||
|
||||
export const initialState = {
|
||||
code: "",
|
||||
path: "",
|
||||
refreshID: 0,
|
||||
fileStates: [] as FileState[],
|
||||
};
|
||||
|
||||
export const codeSlice = createSlice({
|
||||
name: "code",
|
||||
initialState,
|
||||
reducers: {
|
||||
setCode: (state, action) => {
|
||||
state.code = action.payload;
|
||||
},
|
||||
setActiveFilepath: (state, action) => {
|
||||
state.path = action.payload;
|
||||
},
|
||||
setRefreshID: (state, action) => {
|
||||
state.refreshID = action.payload;
|
||||
},
|
||||
setFileStates: (state, action) => {
|
||||
state.fileStates = action.payload;
|
||||
},
|
||||
addOrUpdateFileState: (state, action) => {
|
||||
const { path, unsavedContent, savedContent } = action.payload;
|
||||
const newFileStates = state.fileStates.filter(
|
||||
(fileState) => fileState.path !== path,
|
||||
);
|
||||
newFileStates.push({ path, savedContent, unsavedContent });
|
||||
state.fileStates = newFileStates;
|
||||
},
|
||||
removeFileState: (state, action) => {
|
||||
const path = action.payload;
|
||||
state.fileStates = state.fileStates.filter(
|
||||
(fileState) => fileState.path !== path,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setCode,
|
||||
setActiveFilepath,
|
||||
setRefreshID,
|
||||
addOrUpdateFileState,
|
||||
removeFileState,
|
||||
setFileStates,
|
||||
} = codeSlice.actions;
|
||||
|
||||
export default codeSlice.reducer;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
export type Command = {
|
||||
content: string;
|
||||
type: "input" | "output";
|
||||
};
|
||||
|
||||
const initialCommands: Command[] = [];
|
||||
|
||||
export const commandSlice = createSlice({
|
||||
name: "command",
|
||||
initialState: {
|
||||
commands: initialCommands,
|
||||
},
|
||||
reducers: {
|
||||
appendInput: (state, action) => {
|
||||
state.commands.push({ content: action.payload, type: "input" });
|
||||
},
|
||||
appendOutput: (state, action) => {
|
||||
state.commands.push({ content: action.payload, type: "output" });
|
||||
},
|
||||
clearTerminal: (state) => {
|
||||
state.commands = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { appendInput, appendOutput, clearTerminal } =
|
||||
commandSlice.actions;
|
||||
|
||||
export default commandSlice.reducer;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Command = {
|
||||
content: string;
|
||||
type: "input" | "output";
|
||||
};
|
||||
|
||||
interface CommandState {
|
||||
commands: Command[];
|
||||
appendInput: (content: string) => void;
|
||||
appendOutput: (content: string) => void;
|
||||
clearTerminal: () => void;
|
||||
}
|
||||
|
||||
export const useCommandStore = create<CommandState>((set) => ({
|
||||
commands: [],
|
||||
appendInput: (content: string) =>
|
||||
set((state) => ({
|
||||
commands: [...state.commands, { content, type: "input" }],
|
||||
})),
|
||||
appendOutput: (content: string) =>
|
||||
set((state) => ({
|
||||
commands: [...state.commands, { content, type: "output" }],
|
||||
})),
|
||||
clearTerminal: () => set({ commands: [] }),
|
||||
}));
|
||||
@@ -6,7 +6,8 @@ export type ConversationTab =
|
||||
| "jupyter"
|
||||
| "served"
|
||||
| "vscode"
|
||||
| "terminal";
|
||||
| "terminal"
|
||||
| "tasks";
|
||||
|
||||
export interface IMessageToSend {
|
||||
text: string;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
type SliceState = {
|
||||
files: string[]; // base64 encoded images
|
||||
initialPrompt: string | null;
|
||||
selectedRepository: GitRepository | null;
|
||||
selectedRepositoryProvider: Provider | null;
|
||||
replayJson: string | null;
|
||||
};
|
||||
|
||||
const initialState: SliceState = {
|
||||
files: [],
|
||||
initialPrompt: null,
|
||||
selectedRepository: null,
|
||||
selectedRepositoryProvider: null,
|
||||
replayJson: null,
|
||||
};
|
||||
|
||||
export const selectedFilesSlice = createSlice({
|
||||
name: "initialQuery",
|
||||
initialState,
|
||||
reducers: {
|
||||
addFile(state, action: PayloadAction<string>) {
|
||||
state.files.push(action.payload);
|
||||
},
|
||||
removeFile(state, action: PayloadAction<number>) {
|
||||
state.files.splice(action.payload, 1);
|
||||
},
|
||||
clearFiles(state) {
|
||||
state.files = [];
|
||||
},
|
||||
setInitialPrompt(state, action: PayloadAction<string>) {
|
||||
state.initialPrompt = action.payload;
|
||||
},
|
||||
clearInitialPrompt(state) {
|
||||
state.initialPrompt = null;
|
||||
},
|
||||
setSelectedRepository(state, action: PayloadAction<GitRepository | null>) {
|
||||
state.selectedRepository = action.payload;
|
||||
},
|
||||
clearSelectedRepository(state) {
|
||||
state.selectedRepository = null;
|
||||
},
|
||||
setReplayJson(state, action: PayloadAction<string | null>) {
|
||||
state.replayJson = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addFile,
|
||||
removeFile,
|
||||
clearFiles,
|
||||
setInitialPrompt,
|
||||
clearInitialPrompt,
|
||||
setSelectedRepository,
|
||||
clearSelectedRepository,
|
||||
setReplayJson,
|
||||
} = selectedFilesSlice.actions;
|
||||
export default selectedFilesSlice.reducer;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface MetricsState {
|
||||
cost: number | null;
|
||||
max_budget_per_task: number | null;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
context_window: number;
|
||||
per_turn_token: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const initialState: MetricsState = {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
};
|
||||
|
||||
const metricsSlice = createSlice({
|
||||
name: "metrics",
|
||||
initialState,
|
||||
reducers: {
|
||||
setMetrics: (state, action: PayloadAction<MetricsState>) => {
|
||||
state.cost = action.payload.cost;
|
||||
state.max_budget_per_task = action.payload.max_budget_per_task;
|
||||
state.usage = action.payload.usage;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setMetrics } = metricsSlice.actions;
|
||||
export default metricsSlice.reducer;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { StatusMessage } from "#/types/message";
|
||||
|
||||
const initialStatusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
id: "",
|
||||
message: "",
|
||||
};
|
||||
|
||||
export const statusSlice = createSlice({
|
||||
name: "status",
|
||||
initialState: {
|
||||
curStatusMessage: initialStatusMessage,
|
||||
},
|
||||
reducers: {
|
||||
setCurStatusMessage: (state, action: PayloadAction<StatusMessage>) => {
|
||||
state.curStatusMessage = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCurStatusMessage } = statusSlice.actions;
|
||||
|
||||
export default statusSlice.reducer;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { create } from "zustand";
|
||||
import { StatusMessage } from "#/types/message";
|
||||
|
||||
const initialStatusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
id: "",
|
||||
message: "",
|
||||
};
|
||||
|
||||
interface StatusState {
|
||||
curStatusMessage: StatusMessage;
|
||||
setCurStatusMessage: (message: StatusMessage) => void;
|
||||
}
|
||||
|
||||
export const useStatusStore = create<StatusState>((set) => ({
|
||||
curStatusMessage: initialStatusMessage,
|
||||
setCurStatusMessage: (message: StatusMessage) =>
|
||||
set({ curStatusMessage: message }),
|
||||
}));
|
||||
@@ -1,29 +1,19 @@
|
||||
import { combineReducers, configureStore } from "@reduxjs/toolkit";
|
||||
import agentReducer from "./state/agent-slice";
|
||||
import browserReducer from "./state/browser-slice";
|
||||
import codeReducer from "./state/code-slice";
|
||||
import fileStateReducer from "./state/file-state-slice";
|
||||
import initialQueryReducer from "./state/initial-query-slice";
|
||||
import commandReducer from "./state/command-slice";
|
||||
import { jupyterReducer } from "./state/jupyter-slice";
|
||||
import securityAnalyzerReducer from "./state/security-analyzer-slice";
|
||||
import statusReducer from "./state/status-slice";
|
||||
import metricsReducer from "./state/metrics-slice";
|
||||
import microagentManagementReducer from "./state/microagent-management-slice";
|
||||
import conversationReducer from "./state/conversation-slice";
|
||||
import eventMessageReducer from "./state/event-message-slice";
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
fileState: fileStateReducer,
|
||||
initialQuery: initialQueryReducer,
|
||||
browser: browserReducer,
|
||||
code: codeReducer,
|
||||
cmd: commandReducer,
|
||||
agent: agentReducer,
|
||||
jupyter: jupyterReducer,
|
||||
securityAnalyzer: securityAnalyzerReducer,
|
||||
status: statusReducer,
|
||||
metrics: metricsReducer,
|
||||
microagentManagement: microagentManagementReducer,
|
||||
conversation: conversationReducer,
|
||||
eventMessage: eventMessageReducer,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { create } from "zustand";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
interface InitialQueryState {
|
||||
files: string[]; // base64 encoded images
|
||||
initialPrompt: string | null;
|
||||
selectedRepository: GitRepository | null;
|
||||
selectedRepositoryProvider: Provider | null;
|
||||
replayJson: string | null;
|
||||
}
|
||||
|
||||
interface InitialQueryActions {
|
||||
addFile: (file: string) => void;
|
||||
removeFile: (index: number) => void;
|
||||
clearFiles: () => void;
|
||||
setInitialPrompt: (prompt: string) => void;
|
||||
clearInitialPrompt: () => void;
|
||||
setSelectedRepository: (repository: GitRepository | null) => void;
|
||||
clearSelectedRepository: () => void;
|
||||
setSelectedRepositoryProvider: (provider: Provider | null) => void;
|
||||
setReplayJson: (replayJson: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
type InitialQueryStore = InitialQueryState & InitialQueryActions;
|
||||
|
||||
const initialState: InitialQueryState = {
|
||||
files: [],
|
||||
initialPrompt: null,
|
||||
selectedRepository: null,
|
||||
selectedRepositoryProvider: null,
|
||||
replayJson: null,
|
||||
};
|
||||
|
||||
export const useInitialQueryStore = create<InitialQueryStore>((set) => ({
|
||||
...initialState,
|
||||
|
||||
addFile: (file: string) =>
|
||||
set((state) => ({
|
||||
files: [...state.files, file],
|
||||
})),
|
||||
|
||||
removeFile: (index: number) =>
|
||||
set((state) => ({
|
||||
files: state.files.filter((_, i) => i !== index),
|
||||
})),
|
||||
|
||||
clearFiles: () =>
|
||||
set(() => ({
|
||||
files: [],
|
||||
})),
|
||||
|
||||
setInitialPrompt: (prompt: string) =>
|
||||
set(() => ({
|
||||
initialPrompt: prompt,
|
||||
})),
|
||||
|
||||
clearInitialPrompt: () =>
|
||||
set(() => ({
|
||||
initialPrompt: null,
|
||||
})),
|
||||
|
||||
setSelectedRepository: (repository: GitRepository | null) =>
|
||||
set(() => ({
|
||||
selectedRepository: repository,
|
||||
})),
|
||||
|
||||
clearSelectedRepository: () =>
|
||||
set(() => ({
|
||||
selectedRepository: null,
|
||||
})),
|
||||
|
||||
setSelectedRepositoryProvider: (provider: Provider | null) =>
|
||||
set(() => ({
|
||||
selectedRepositoryProvider: provider,
|
||||
})),
|
||||
|
||||
setReplayJson: (replayJson: string | null) =>
|
||||
set(() => ({
|
||||
replayJson,
|
||||
})),
|
||||
|
||||
reset: () => set(() => initialState),
|
||||
}));
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface MetricsState {
|
||||
cost: number | null;
|
||||
max_budget_per_task: number | null;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
context_window: number;
|
||||
per_turn_token: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface MetricsStore extends MetricsState {
|
||||
setMetrics: (metrics: MetricsState) => void;
|
||||
}
|
||||
|
||||
const useMetricsStore = create<MetricsStore>((set) => ({
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
setMetrics: (metrics) => set(metrics),
|
||||
}));
|
||||
|
||||
export default useMetricsStore;
|
||||
@@ -5,6 +5,7 @@ const typographyVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
h1: "text-[32px] text-white font-bold leading-5",
|
||||
h3: "text-sm font-semibold text-gray-300",
|
||||
span: "text-sm font-normal text-white leading-5.5",
|
||||
codeBlock:
|
||||
"font-mono text-sm leading-relaxed text-gray-300 whitespace-pre-wrap",
|
||||
@@ -52,6 +53,18 @@ export function H1({
|
||||
);
|
||||
}
|
||||
|
||||
export function H3({
|
||||
className,
|
||||
testId,
|
||||
children,
|
||||
}: Omit<TypographyProps, "variant">) {
|
||||
return (
|
||||
<Typography variant="h3" className={className} testId={testId}>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
export function Text({
|
||||
className,
|
||||
testId,
|
||||
@@ -78,5 +91,6 @@ export function CodeBlock({
|
||||
|
||||
// Attach components to Typography for the expected API
|
||||
Typography.H1 = H1;
|
||||
Typography.H3 = H3;
|
||||
Typography.Text = Text;
|
||||
Typography.CodeBlock = CodeBlock;
|
||||
|
||||
@@ -474,3 +474,38 @@ export const getConversationStatusLabel = (
|
||||
return "COMMON$UNKNOWN";
|
||||
}
|
||||
};
|
||||
|
||||
// Task Tracking Utility Functions
|
||||
|
||||
/**
|
||||
* Get the status icon for a task status
|
||||
* @param status The task status
|
||||
* @returns The emoji icon for the status
|
||||
*/
|
||||
export const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return "⏳";
|
||||
case "in_progress":
|
||||
return "🔄";
|
||||
case "done":
|
||||
return "✅";
|
||||
default:
|
||||
return "❓";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the CSS class names for a task status badge
|
||||
* @param status The task status
|
||||
* @returns The CSS class names for styling the status badge
|
||||
*/
|
||||
export const getStatusClassName = (status: string) => {
|
||||
if (status === "done") {
|
||||
return "bg-green-800 text-green-200";
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return "bg-yellow-800 text-yellow-200";
|
||||
}
|
||||
return "bg-gray-700 text-gray-300";
|
||||
};
|
||||
|
||||
@@ -79,6 +79,28 @@ export function renderWithProviders(
|
||||
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
|
||||
}
|
||||
|
||||
// Export a render function for components that only need QueryClient and i18next providers
|
||||
// (without Redux store)
|
||||
export function renderWithQueryAndI18n(
|
||||
ui: React.ReactElement,
|
||||
renderOptions: Omit<RenderOptions, "wrapper"> = {},
|
||||
) {
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
return render(ui, { wrapper: Wrapper, ...renderOptions });
|
||||
}
|
||||
|
||||
export const createAxiosNotFoundErrorObject = () =>
|
||||
new AxiosError(
|
||||
"Request failed with status code 404",
|
||||
|
||||
@@ -161,7 +161,6 @@ class EventStream(EventStore):
|
||||
self._clean_up_subscriber(subscriber_id, callback_id)
|
||||
|
||||
def add_event(self, event: Event, source: EventSource) -> None:
|
||||
logger.info(f'Adding event with ID {event.id}')
|
||||
if event.id != Event.INVALID_ID:
|
||||
raise ValueError(
|
||||
f'Event already has an ID:{event.id}. It was probably added back to the EventStream from inside a handler, triggering a loop.'
|
||||
@@ -184,7 +183,6 @@ class EventStream(EventStore):
|
||||
if len(current_write_page) == self.cache_size:
|
||||
self._write_page_cache = []
|
||||
|
||||
logger.info(f'Event now has ID {event.id}')
|
||||
if event.id is not None:
|
||||
# Write the event to the store - this can take some time
|
||||
event_json = json.dumps(data)
|
||||
@@ -206,18 +204,12 @@ class EventStream(EventStore):
|
||||
|
||||
def _store_cache_page(self, current_write_page: list[dict]):
|
||||
"""Store a page in the cache. Reading individual events is slow when there are a lot of them, so we use pages."""
|
||||
logger.info(
|
||||
f'Writing event cache if page len {len(current_write_page)} is greater than {self.cache_size}'
|
||||
)
|
||||
if len(current_write_page) < self.cache_size:
|
||||
return
|
||||
start = current_write_page[0]['id']
|
||||
end = start + self.cache_size
|
||||
contents = json.dumps(current_write_page)
|
||||
cache_filename = self._get_filename_for_cache(start, end)
|
||||
logger.info(
|
||||
f'writing event cache to {cache_filename} in file store of type {type(self.file_store)}'
|
||||
)
|
||||
self.file_store.write(cache_filename, contents)
|
||||
|
||||
def set_secrets(self, secrets: dict[str, str]) -> None:
|
||||
|
||||
@@ -188,7 +188,10 @@ class ProviderHandler:
|
||||
return SecretStr(data.token)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to fetch latest token for provider {provider}: {e}')
|
||||
logger.error(
|
||||
f'Failed to fetch latest token for provider {provider}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -340,10 +340,16 @@ class Runtime(FileEditRuntimeMixin):
|
||||
sid=self.sid,
|
||||
)
|
||||
|
||||
logger.info(f'Fetching latest provider tokens for runtime: {self.sid}')
|
||||
logger.info(
|
||||
f'Fetching latest provider tokens for runtime: {self.sid}, '
|
||||
f'providers: {providers_called}'
|
||||
)
|
||||
env_vars = await provider_handler.get_env_vars(
|
||||
providers=providers_called, expose_secrets=False, get_latest=True
|
||||
)
|
||||
logger.info(
|
||||
f'Successfully fetched {len(env_vars)} token(s) for runtime: {self.sid}'
|
||||
)
|
||||
|
||||
if len(env_vars) == 0:
|
||||
return
|
||||
@@ -355,8 +361,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
)
|
||||
self.add_env_vars(provider_handler.expose_env_vars(env_vars))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed export latest github token to runtime: {self.sid}, {e}'
|
||||
logger.error(
|
||||
f'Failed to export latest github token to runtime: {self.sid}, {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _handle_action(self, event: Action) -> None:
|
||||
|
||||
@@ -129,15 +129,11 @@ class ActionExecutionClient(Runtime):
|
||||
return send_request(self.session, method, url, **kwargs)
|
||||
|
||||
def check_if_alive(self) -> None:
|
||||
request_url = f'{self.action_execution_server_url}/alive'
|
||||
self.log('debug', f'Sending request to: {request_url}')
|
||||
response = self._send_action_server_request(
|
||||
'GET',
|
||||
request_url,
|
||||
f'{self.action_execution_server_url}/alive',
|
||||
timeout=5,
|
||||
)
|
||||
self.log('debug', f'Response status code: {response.status_code}')
|
||||
self.log('debug', f'Response text: {response.text}')
|
||||
assert response.is_closed
|
||||
|
||||
def list_files(self, path: str | None = None) -> list[str]:
|
||||
|
||||
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
|
||||
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
|
||||
```toml
|
||||
[sandbox]
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik"
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -415,19 +415,11 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
|
||||
def _wait_until_alive_impl(self) -> None:
|
||||
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
|
||||
self.log(
|
||||
'debug',
|
||||
f'Sending request to: {self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
|
||||
)
|
||||
runtime_info_response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
|
||||
)
|
||||
runtime_data = runtime_info_response.json()
|
||||
self.log(
|
||||
'debug',
|
||||
f'received response: {runtime_data}',
|
||||
)
|
||||
assert 'runtime_id' in runtime_data
|
||||
assert runtime_data['runtime_id'] == self.runtime_id
|
||||
assert 'pod_status' in runtime_data
|
||||
|
||||
@@ -13,15 +13,6 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JupyterRequirement(PluginRequirement):
|
||||
@@ -45,7 +36,7 @@ class JupyterPlugin(Plugin):
|
||||
|
||||
if not is_local_runtime:
|
||||
# Non-LocalRuntime
|
||||
prefix = f'su - {username} -s ' if SU_TO_USER else ''
|
||||
prefix = f'su - {username} -s '
|
||||
# cd to code repo, setup all env vars and run micromamba
|
||||
poetry_prefix = (
|
||||
'cd /openhands/code\n'
|
||||
|
||||
@@ -15,16 +15,6 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VSCodeRequirement(PluginRequirement):
|
||||
@@ -47,7 +37,7 @@ class VSCodePlugin(Plugin):
|
||||
)
|
||||
return
|
||||
|
||||
if username not in filter(None, [RUNTIME_USERNAME, 'root', 'openhands']):
|
||||
if username not in ['root', 'openhands']:
|
||||
self.vscode_port = None
|
||||
self.vscode_connection_token = None
|
||||
logger.warning(
|
||||
@@ -93,19 +83,13 @@ class VSCodePlugin(Plugin):
|
||||
if path_mode:
|
||||
base_path_flag = f' --server-base-path /{runtime_id}/vscode'
|
||||
|
||||
cmd = (
|
||||
(
|
||||
f"su - {username} -s /bin/bash << 'EOF'\n"
|
||||
if SU_TO_USER
|
||||
else "/bin/bash << 'EOF'\n"
|
||||
)
|
||||
+ f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
|
||||
+ f'cd {workspace_path}\n'
|
||||
+ 'exec /openhands/.openvscode-server/bin/openvscode-server '
|
||||
+ f'--host 0.0.0.0 --connection-token {self.vscode_connection_token} '
|
||||
+ f'--port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
|
||||
+ 'EOF'
|
||||
)
|
||||
cmd = (
|
||||
f"su - {username} -s /bin/bash << 'EOF'\n"
|
||||
f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
|
||||
f'cd {workspace_path}\n'
|
||||
f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --connection-token {self.vscode_connection_token} --port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
|
||||
'EOF'
|
||||
)
|
||||
|
||||
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
|
||||
# to avoid ASYNC101 linting error
|
||||
|
||||
@@ -20,16 +20,6 @@ from openhands.events.observation.commands import (
|
||||
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
def split_bash_commands(commands: str) -> list[str]:
|
||||
if not commands.strip():
|
||||
@@ -203,9 +193,7 @@ class BashSession:
|
||||
def initialize(self) -> None:
|
||||
self.server = libtmux.Server()
|
||||
_shell_command = '/bin/bash'
|
||||
if SU_TO_USER and self.username in list(
|
||||
filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])
|
||||
):
|
||||
if self.username in ['root', 'openhands']:
|
||||
# This starts a non-login (new) shell for the given user
|
||||
_shell_command = f'su {self.username} -'
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import os
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
@@ -14,9 +12,6 @@ DEFAULT_PYTHON_PREFIX = [
|
||||
]
|
||||
DEFAULT_MAIN_MODULE = 'openhands.runtime.action_execution_server'
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
RUNTIME_UID = os.getenv('RUNTIME_UID')
|
||||
|
||||
|
||||
def get_action_execution_server_startup_command(
|
||||
server_port: int,
|
||||
@@ -31,10 +26,7 @@ def get_action_execution_server_startup_command(
|
||||
sandbox_config = app_config.sandbox
|
||||
logger.debug(f'app_config {vars(app_config)}')
|
||||
logger.debug(f'sandbox_config {vars(sandbox_config)}')
|
||||
logger.debug(f'RUNTIME_USERNAME {RUNTIME_USERNAME}, RUNTIME_UID {RUNTIME_UID}')
|
||||
logger.debug(
|
||||
f'override_username {override_username}, override_user_id {override_user_id}'
|
||||
)
|
||||
logger.debug(f'override_user_id {override_user_id}')
|
||||
|
||||
# Plugin args
|
||||
plugin_args = []
|
||||
@@ -48,15 +40,10 @@ def get_action_execution_server_startup_command(
|
||||
'--browsergym-eval-env'
|
||||
] + sandbox_config.browsergym_eval_env.split(' ')
|
||||
|
||||
username = (
|
||||
override_username
|
||||
or RUNTIME_USERNAME
|
||||
or ('openhands' if app_config.run_as_openhands else 'root')
|
||||
username = override_username or (
|
||||
'openhands' if app_config.run_as_openhands else 'root'
|
||||
)
|
||||
user_id = (
|
||||
override_user_id or RUNTIME_UID or (1000 if app_config.run_as_openhands else 0)
|
||||
)
|
||||
logger.debug(f'username {username}, user_id {user_id}')
|
||||
user_id = override_user_id or (1000 if app_config.run_as_openhands else 0)
|
||||
|
||||
base_cmd = [
|
||||
*python_prefix,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
@@ -63,11 +62,6 @@ class BatchedWebHookFileStore(FileStore):
|
||||
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.
|
||||
"""
|
||||
logger.info(
|
||||
f'BatchedWebHookFileStore __init__ called with filestore type {type(file_store)}'
|
||||
)
|
||||
stack = '\n'.join(traceback.format_stack())
|
||||
logger.info('BatchedWebHookFileStore __init__ stack trace:\n%s', stack)
|
||||
self.file_store = file_store
|
||||
self.base_url = base_url
|
||||
if client is None:
|
||||
@@ -95,9 +89,6 @@ class BatchedWebHookFileStore(FileStore):
|
||||
path: The path to write to
|
||||
contents: The contents to write
|
||||
"""
|
||||
logger.info(
|
||||
f'BatchedWebHookFileStore write to {path} in filestore of type {type(self.file_store)}'
|
||||
)
|
||||
self.file_store.write(path, contents)
|
||||
self._queue_update(path, 'write', contents)
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import os
|
||||
import traceback
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import boto3
|
||||
import botocore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
|
||||
@@ -39,8 +37,6 @@ class S3FileStore(FileStore):
|
||||
)
|
||||
|
||||
def write(self, path: str, contents: str | bytes) -> None:
|
||||
stack = '\n'.join(traceback.format_stack())
|
||||
logger.info('S3FileStore write stack trace:\n%s', stack)
|
||||
try:
|
||||
as_bytes = (
|
||||
contents.encode('utf-8') if isinstance(contents, str) else contents
|
||||
|
||||
@@ -27,7 +27,6 @@ class HttpSession:
|
||||
headers = kwargs.get('headers') or {}
|
||||
headers = {**self.headers, **headers}
|
||||
kwargs['headers'] = headers
|
||||
logger.debug(f'HttpSession:request called with args {args} and kwargs {kwargs}')
|
||||
return CLIENT.request(*args, **kwargs)
|
||||
|
||||
def stream(self, *args, **kwargs):
|
||||
|
||||
Generated
+134
-108
@@ -2453,19 +2453,19 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.116.1"
|
||||
version = "0.116.2"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"},
|
||||
{file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"},
|
||||
{file = "fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db"},
|
||||
{file = "fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
|
||||
starlette = ">=0.40.0,<0.48.0"
|
||||
starlette = ">=0.40.0,<0.49.0"
|
||||
typing-extensions = ">=4.8.0"
|
||||
|
||||
[package.extras]
|
||||
@@ -6883,101 +6883,126 @@ ptyprocess = ">=0.5"
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.2.1"
|
||||
version = "11.3.0"
|
||||
description = "Python Imaging Library (Fork)"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"},
|
||||
{file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"},
|
||||
{file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"},
|
||||
{file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"},
|
||||
{file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"},
|
||||
{file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"},
|
||||
{file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"},
|
||||
{file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"},
|
||||
{file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"},
|
||||
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"},
|
||||
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"},
|
||||
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"},
|
||||
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"},
|
||||
{file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"},
|
||||
{file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"},
|
||||
{file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"},
|
||||
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"},
|
||||
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"},
|
||||
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"},
|
||||
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"},
|
||||
{file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"},
|
||||
{file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"},
|
||||
{file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"},
|
||||
{file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"},
|
||||
{file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"},
|
||||
{file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"},
|
||||
{file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"},
|
||||
{file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"},
|
||||
{file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"},
|
||||
{file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"},
|
||||
{file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"},
|
||||
{file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"},
|
||||
{file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"},
|
||||
{file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"},
|
||||
{file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"},
|
||||
{file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"},
|
||||
{file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"},
|
||||
{file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"},
|
||||
{file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"},
|
||||
{file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"},
|
||||
{file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"},
|
||||
{file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"},
|
||||
{file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"},
|
||||
{file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"},
|
||||
{file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
|
||||
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
|
||||
fpx = ["olefile"]
|
||||
mic = ["olefile"]
|
||||
test-arrow = ["pyarrow"]
|
||||
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"]
|
||||
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
|
||||
typing = ["typing-extensions ; python_version < \"3.10\""]
|
||||
xmp = ["defusedxml"]
|
||||
|
||||
@@ -7828,14 +7853,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "5.6.0"
|
||||
version = "6.0.0"
|
||||
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pypdf-5.6.0-py3-none-any.whl", hash = "sha256:ca6bf446bfb0a2d8d71d6d6bb860798d864c36a29b3d9ae8d7fc7958c59f88e7"},
|
||||
{file = "pypdf-5.6.0.tar.gz", hash = "sha256:a4b6538b77fc796622000db7127e4e58039ec5e6afd292f8e9bf42e2e985a749"},
|
||||
{file = "pypdf-6.0.0-py3-none-any.whl", hash = "sha256:56ea60100ce9f11fc3eec4f359da15e9aec3821b036c1f06d2b660d35683abb8"},
|
||||
{file = "pypdf-6.0.0.tar.gz", hash = "sha256:282a99d2cc94a84a3a3159f0d9358c0af53f85b4d28d76ea38b96e9e5ac2a08d"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -8791,19 +8816,19 @@ shaping = ["uharfbuzz"]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
version = "2.32.5"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation", "runtime", "test"]
|
||||
files = [
|
||||
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
|
||||
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
|
||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
charset-normalizer = ">=2,<4"
|
||||
charset_normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7", optional = true, markers = "extra == \"socks\""}
|
||||
urllib3 = ">=1.21.1,<3"
|
||||
@@ -9592,14 +9617,14 @@ whisper-local = ["openai-whisper", "soundfile"]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "2.4.1"
|
||||
version = "3.0.2"
|
||||
description = "SSE plugin for Starlette"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a"},
|
||||
{file = "sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926"},
|
||||
{file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"},
|
||||
{file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -9607,7 +9632,7 @@ anyio = ">=4.7.0"
|
||||
|
||||
[package.extras]
|
||||
daphne = ["daphne (>=4.2.0)"]
|
||||
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio,examples] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"]
|
||||
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"]
|
||||
granian = ["granian (>=2.3.1)"]
|
||||
uvicorn = ["uvicorn (>=0.34.0)"]
|
||||
|
||||
@@ -9663,18 +9688,19 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.46.2"
|
||||
version = "0.48.0"
|
||||
description = "The little ASGI library that shines."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"},
|
||||
{file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"},
|
||||
{file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"},
|
||||
{file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.6.2,<5"
|
||||
typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
|
||||
@@ -10677,14 +10703,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation", "runtime", "test"]
|
||||
files = [
|
||||
{file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"},
|
||||
{file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"},
|
||||
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
|
||||
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -11823,4 +11849,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "6c7bc9a39d6875e09966872a5d579e73b5cb739d1bad89d3a7dde829541cec16"
|
||||
content-hash = "5914d56ec4c81b3a1e4ba141a89233c287a435f358d67be496a6f1230c1e1610"
|
||||
|
||||
+9
-2
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
@@ -65,7 +65,7 @@ pygithub = "^2.5.0"
|
||||
joblib = "*"
|
||||
openhands-aci = "0.3.2"
|
||||
python-socketio = "^5.11.4"
|
||||
sse-starlette = "^2.1.3"
|
||||
sse-starlette = "^3.0.2"
|
||||
psutil = "*"
|
||||
python-json-logger = "^3.2.1"
|
||||
prompt-toolkit = "^3.0.50"
|
||||
@@ -84,6 +84,13 @@ pylatexenc = "*"
|
||||
python-docx = "*"
|
||||
bashlex = "^0.18"
|
||||
|
||||
# Explicitly pinned packages for latest versions
|
||||
pypdf = "^6.0.0"
|
||||
pillow = "^11.3.0"
|
||||
starlette = "^0.48.0"
|
||||
urllib3 = "^2.5.0"
|
||||
requests = "^2.32.5"
|
||||
|
||||
# TODO: These are integrations that should probably be optional
|
||||
redis = ">=5.2,<7.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Unit tests to verify pricing documentation consistency.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
class TestPricingDocumentation:
|
||||
"""Test class for pricing documentation consistency."""
|
||||
|
||||
@pytest.fixture
|
||||
def pricing_data(self) -> dict[str, Any]:
|
||||
"""Fetch pricing data from LiteLLM repository."""
|
||||
url = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@pytest.fixture
|
||||
def openhands_models(self) -> list[str]:
|
||||
"""Get the list of OpenHands models from the codebase."""
|
||||
# Read the models directly from the source code file
|
||||
llm_utils_path = (
|
||||
Path(__file__).parent.parent.parent / 'openhands' / 'utils' / 'llm.py'
|
||||
)
|
||||
content = llm_utils_path.read_text()
|
||||
|
||||
# Extract the openhands_models list from the file
|
||||
import ast
|
||||
|
||||
# Parse the Python file
|
||||
tree = ast.parse(content)
|
||||
|
||||
# Find the openhands_models assignment
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id == 'openhands_models'
|
||||
):
|
||||
# Extract the list values
|
||||
if isinstance(node.value, ast.List):
|
||||
models = []
|
||||
for elt in node.value.elts:
|
||||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
||||
# Remove 'openhands/' prefix and filter out secret models
|
||||
model = elt.value
|
||||
if model.startswith('openhands/'):
|
||||
model = model[10:] # Remove 'openhands/' prefix
|
||||
if not model.startswith('<secret'):
|
||||
models.append(model)
|
||||
return models
|
||||
|
||||
# Fallback if parsing fails
|
||||
raise ValueError('Could not extract openhands_models from llm.py')
|
||||
|
||||
@pytest.fixture
|
||||
def documentation_content(self) -> str:
|
||||
"""Read the OpenHands LLM documentation content."""
|
||||
docs_path = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ 'docs'
|
||||
/ 'usage'
|
||||
/ 'llms'
|
||||
/ 'openhands-llms.mdx'
|
||||
)
|
||||
return docs_path.read_text()
|
||||
|
||||
def extract_pricing_from_docs(self, content: str) -> dict[str, dict[str, float]]:
|
||||
"""Extract pricing information from documentation."""
|
||||
# Updated pattern to handle cached input cost column (which can be N/A)
|
||||
pricing_table_pattern = (
|
||||
r'\| ([^|]+) \| \$([0-9.]+) \| ([^|]+) \| \$([0-9.]+) \|'
|
||||
)
|
||||
matches = re.findall(pricing_table_pattern, content)
|
||||
|
||||
pricing_data = {}
|
||||
for match in matches:
|
||||
model_name = match[0].strip()
|
||||
input_cost = float(match[1])
|
||||
cached_input_str = match[2].strip()
|
||||
output_cost = float(match[3])
|
||||
|
||||
# Parse cached input cost (can be N/A or $X.XX)
|
||||
cached_input_cost = None
|
||||
if cached_input_str != 'N/A':
|
||||
cached_input_cost = float(cached_input_str.replace('$', ''))
|
||||
|
||||
pricing_data[model_name] = {
|
||||
'input_cost_per_million_tokens': input_cost,
|
||||
'cached_input_cost_per_million_tokens': cached_input_cost,
|
||||
'output_cost_per_million_tokens': output_cost,
|
||||
}
|
||||
|
||||
return pricing_data
|
||||
|
||||
def get_litellm_pricing(
|
||||
self, model: str, pricing_data: dict[str, Any]
|
||||
) -> dict[str, float]:
|
||||
"""Get pricing for a model from LiteLLM data."""
|
||||
# Try different variations of the model name
|
||||
variations = [
|
||||
model,
|
||||
f'openai/{model}',
|
||||
f'anthropic/{model}',
|
||||
f'google/{model}',
|
||||
f'mistral/{model}',
|
||||
]
|
||||
|
||||
for variation in variations:
|
||||
if variation in pricing_data:
|
||||
model_data = pricing_data[variation]
|
||||
result = {
|
||||
'input_cost_per_million_tokens': model_data.get(
|
||||
'input_cost_per_token', 0
|
||||
)
|
||||
* 1_000_000,
|
||||
'output_cost_per_million_tokens': model_data.get(
|
||||
'output_cost_per_token', 0
|
||||
)
|
||||
* 1_000_000,
|
||||
}
|
||||
|
||||
# Add cached input cost if available
|
||||
cached_cost = model_data.get('cache_read_input_token_cost', 0)
|
||||
if cached_cost > 0:
|
||||
result['cached_input_cost_per_million_tokens'] = (
|
||||
cached_cost * 1_000_000
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
return {}
|
||||
|
||||
def test_pricing_table_exists(self, documentation_content: str):
|
||||
"""Test that the pricing table exists in the documentation."""
|
||||
assert (
|
||||
'| Model | Input Cost (per 1M tokens) | Cached Input Cost (per 1M tokens) | Output Cost (per 1M tokens)'
|
||||
in documentation_content
|
||||
)
|
||||
assert 'claude-opus-4-20250514' in documentation_content
|
||||
assert 'qwen3-coder-480b' in documentation_content
|
||||
|
||||
def test_no_external_json_link(self, documentation_content: str):
|
||||
"""Test that the external JSON link has been removed."""
|
||||
assert (
|
||||
'github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json'
|
||||
not in documentation_content
|
||||
)
|
||||
|
||||
def test_pricing_consistency_with_litellm(
|
||||
self, pricing_data: dict[str, Any], documentation_content: str
|
||||
):
|
||||
"""Test that pricing in documentation matches LiteLLM data where applicable."""
|
||||
docs_pricing = self.extract_pricing_from_docs(documentation_content)
|
||||
|
||||
# Special case for qwen3-coder-480b (custom pricing)
|
||||
qwen_pricing = docs_pricing.get('qwen3-coder-480b')
|
||||
assert qwen_pricing is not None
|
||||
assert qwen_pricing['input_cost_per_million_tokens'] == 0.4
|
||||
assert qwen_pricing['output_cost_per_million_tokens'] == 1.6
|
||||
assert qwen_pricing['cached_input_cost_per_million_tokens'] is None # N/A
|
||||
|
||||
# Test other models against LiteLLM data
|
||||
for model_name, doc_pricing in docs_pricing.items():
|
||||
if model_name == 'qwen3-coder-480b':
|
||||
continue # Skip custom pricing model
|
||||
|
||||
litellm_pricing = self.get_litellm_pricing(model_name, pricing_data)
|
||||
|
||||
if litellm_pricing: # Only test if we found pricing in LiteLLM
|
||||
assert (
|
||||
abs(
|
||||
doc_pricing['input_cost_per_million_tokens']
|
||||
- litellm_pricing['input_cost_per_million_tokens']
|
||||
)
|
||||
< 0.01
|
||||
), (
|
||||
f'Input pricing mismatch for {model_name}: docs={doc_pricing["input_cost_per_million_tokens"]}, litellm={litellm_pricing["input_cost_per_million_tokens"]}'
|
||||
)
|
||||
|
||||
assert (
|
||||
abs(
|
||||
doc_pricing['output_cost_per_million_tokens']
|
||||
- litellm_pricing['output_cost_per_million_tokens']
|
||||
)
|
||||
< 0.01
|
||||
), (
|
||||
f'Output pricing mismatch for {model_name}: docs={doc_pricing["output_cost_per_million_tokens"]}, litellm={litellm_pricing["output_cost_per_million_tokens"]}'
|
||||
)
|
||||
|
||||
# Test cached input cost if both have it
|
||||
doc_cached = doc_pricing.get('cached_input_cost_per_million_tokens')
|
||||
litellm_cached = litellm_pricing.get(
|
||||
'cached_input_cost_per_million_tokens'
|
||||
)
|
||||
|
||||
if doc_cached is not None and litellm_cached is not None:
|
||||
assert abs(doc_cached - litellm_cached) < 0.01, (
|
||||
f'Cached input pricing mismatch for {model_name}: docs={doc_cached}, litellm={litellm_cached}'
|
||||
)
|
||||
elif doc_cached is None and litellm_cached is not None:
|
||||
# Documentation shows N/A but LiteLLM has cached pricing - this might be intentional
|
||||
pass
|
||||
elif doc_cached is not None and litellm_cached is None:
|
||||
# Documentation has cached pricing but LiteLLM doesn't - this shouldn't happen
|
||||
raise AssertionError(
|
||||
f'Documentation has cached pricing for {model_name} but LiteLLM does not'
|
||||
)
|
||||
|
||||
def test_all_openhands_models_documented(
|
||||
self, openhands_models: list[str], documentation_content: str
|
||||
):
|
||||
"""Test that all OpenHands models are documented in the pricing table."""
|
||||
docs_pricing = self.extract_pricing_from_docs(documentation_content)
|
||||
documented_models = set(docs_pricing.keys())
|
||||
|
||||
# Filter out models that might not have pricing (like kimi-k2-0711-preview)
|
||||
expected_models = set(openhands_models)
|
||||
|
||||
# Check that most models are documented (allowing for some models without pricing)
|
||||
documented_count = len(documented_models.intersection(expected_models))
|
||||
total_count = len(expected_models)
|
||||
|
||||
# We should have at least 80% of models documented
|
||||
coverage_ratio = documented_count / total_count if total_count > 0 else 0
|
||||
assert coverage_ratio >= 0.8, (
|
||||
f'Only {documented_count}/{total_count} models documented in pricing table'
|
||||
)
|
||||
|
||||
def test_model_list_consistency(
|
||||
self, openhands_models: list[str], documentation_content: str
|
||||
):
|
||||
"""Test that the model list in documentation is consistent with the code."""
|
||||
docs_pricing = self.extract_pricing_from_docs(documentation_content)
|
||||
documented_models = set(docs_pricing.keys())
|
||||
code_models = set(openhands_models)
|
||||
|
||||
# Find models that are in code but not in docs
|
||||
missing_from_docs = code_models - documented_models
|
||||
# Find models that are in docs but not in code
|
||||
extra_in_docs = documented_models - code_models
|
||||
|
||||
# Allow some models to be missing from docs (e.g., if they don't have pricing)
|
||||
# but no extra models should be in docs that aren't in code
|
||||
assert not extra_in_docs, (
|
||||
f'Models in documentation but not in code: {extra_in_docs}'
|
||||
)
|
||||
|
||||
# Report missing models for visibility (but don't fail the test)
|
||||
if missing_from_docs:
|
||||
print(f'Models in code but not documented: {missing_from_docs}')
|
||||
|
||||
def test_pricing_format_consistency(self, documentation_content: str):
|
||||
"""Test that pricing format is consistent in the documentation."""
|
||||
docs_pricing = self.extract_pricing_from_docs(documentation_content)
|
||||
|
||||
for model_name, pricing in docs_pricing.items():
|
||||
# Check that prices are reasonable (not negative, not extremely high)
|
||||
assert pricing['input_cost_per_million_tokens'] >= 0, (
|
||||
f'Negative input cost for {model_name}'
|
||||
)
|
||||
assert pricing['output_cost_per_million_tokens'] >= 0, (
|
||||
f'Negative output cost for {model_name}'
|
||||
)
|
||||
assert pricing['input_cost_per_million_tokens'] <= 100, (
|
||||
f'Unreasonably high input cost for {model_name}'
|
||||
)
|
||||
assert pricing['output_cost_per_million_tokens'] <= 200, (
|
||||
f'Unreasonably high output cost for {model_name}'
|
||||
)
|
||||
|
||||
# Output cost should generally be higher than input cost
|
||||
if pricing['input_cost_per_million_tokens'] > 0:
|
||||
ratio = (
|
||||
pricing['output_cost_per_million_tokens']
|
||||
/ pricing['input_cost_per_million_tokens']
|
||||
)
|
||||
assert ratio >= 1.0, (
|
||||
f'Output cost should be >= input cost for {model_name}'
|
||||
)
|
||||
assert ratio <= 20.0, (
|
||||
f'Output/input cost ratio too high for {model_name}'
|
||||
)
|
||||
Reference in New Issue
Block a user