mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Merge branch 'main' into ray/enforce-coverage-soft
This commit is contained in:
commit
d9db38b68b
4
.github/scripts/update_pr_description.sh
vendored
4
.github/scripts/update_pr_description.sh
vendored
@ -13,9 +13,9 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
|
||||
--name openhands-app-${SHORT_SHA} \
|
||||
docker.all-hands.dev/openhands/openhands:${SHORT_SHA}"
|
||||
docker.openhands.dev/openhands/openhands:${SHORT_SHA}"
|
||||
|
||||
# Define the uvx command
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/OpenHands/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands"
|
||||
|
||||
@ -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/openhands/runtime:0.59-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.60-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@ -82,17 +82,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.59-nikolaik
|
||||
docker pull docker.openhands.dev/openhands/runtime:0.60-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.60-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.59
|
||||
docker.openhands.dev/openhands/openhands:0.60
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@ -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/openhands/runtime:0.59-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.60-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@ -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/openhands/runtime:0.59-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.60-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:
|
||||
|
||||
79
evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py
Normal file
79
evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py
Normal file
@ -0,0 +1,79 @@
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_final_reports(base_dir, pattern=None):
|
||||
base_path = Path(base_dir)
|
||||
if not base_path.exists():
|
||||
raise FileNotFoundError(f'Base directory does not exist: {base_dir}')
|
||||
|
||||
# Find all final_report.json files
|
||||
all_reports = list(base_path.rglob('final_report.json'))
|
||||
|
||||
if pattern is None:
|
||||
return all_reports
|
||||
|
||||
# Filter by pattern
|
||||
filtered_reports = []
|
||||
for report in all_reports:
|
||||
# Get relative path from base_dir for matching
|
||||
rel_path = report.relative_to(base_path)
|
||||
if fnmatch.fnmatch(str(rel_path), pattern):
|
||||
filtered_reports.append(report)
|
||||
|
||||
return filtered_reports
|
||||
|
||||
|
||||
def collect_resolved_ids(report_files):
|
||||
id_counter = Counter()
|
||||
|
||||
for report_file in report_files:
|
||||
with open(report_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
if 'resolved_ids' not in data:
|
||||
raise KeyError(f"'resolved_ids' key not found in {report_file}")
|
||||
resolved_ids = data['resolved_ids']
|
||||
id_counter.update(resolved_ids)
|
||||
|
||||
return id_counter
|
||||
|
||||
|
||||
def get_skip_ids(id_counter, threshold):
|
||||
return [id_str for id_str, count in id_counter.items() if count >= threshold]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Compute SKIP_IDS from resolved IDs in final_report.json files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'threshold',
|
||||
type=int,
|
||||
help='Minimum number of times an ID must be resolved to be skipped',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--base-dir',
|
||||
default='evaluation/evaluation_outputs/outputs',
|
||||
help='Base directory to search for final_report.json files (default: evaluation/evaluation_outputs/outputs)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--pattern',
|
||||
default=None,
|
||||
help='Glob pattern to filter paths (e.g., "*Multi-SWE-RL*/**/*gpt*")',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
report_files = find_final_reports(args.base_dir, args.pattern)
|
||||
id_counter = collect_resolved_ids(report_files)
|
||||
|
||||
skip_ids = get_skip_ids(id_counter, args.threshold)
|
||||
skip_ids = [s.replace('/', '__').replace(':pr-', '-') for s in skip_ids]
|
||||
skip_ids = ','.join(sorted(skip_ids))
|
||||
print(skip_ids)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -747,10 +747,14 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
subset = dataset[dataset[filter_column].isin(selected_ids)]
|
||||
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
|
||||
return subset
|
||||
skip_ids = os.environ.get('SKIP_IDS', '').split(',')
|
||||
skip_ids = [id for id in os.environ.get('SKIP_IDS', '').split(',') if id]
|
||||
if len(skip_ids) > 0:
|
||||
logger.info(f'Dataset size before filtering: {dataset.shape[0]} tasks')
|
||||
logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...')
|
||||
return dataset[~dataset[filter_column].isin(skip_ids)]
|
||||
logger.info(f'SKIP_IDS:\n{skip_ids}')
|
||||
filtered_dataset = dataset[~dataset[filter_column].isin(skip_ids)]
|
||||
logger.info(f'Dataset size after filtering: {filtered_dataset.shape[0]} tasks')
|
||||
return filtered_dataset
|
||||
return dataset
|
||||
|
||||
|
||||
@ -768,6 +772,11 @@ if __name__ == '__main__':
|
||||
default='test',
|
||||
help='split to evaluate on',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--filter_dataset_after_sampling',
|
||||
action='store_true',
|
||||
help='if provided, filter dataset after sampling instead of before',
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
# NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing
|
||||
@ -777,10 +786,24 @@ if __name__ == '__main__':
|
||||
logger.info(f'Loading dataset {args.dataset} with split {args.split} ')
|
||||
dataset = load_dataset('json', data_files=args.dataset)
|
||||
dataset = dataset[args.split]
|
||||
swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
|
||||
)
|
||||
swe_bench_tests = dataset.to_pandas()
|
||||
|
||||
# Determine filter strategy based on flag
|
||||
filter_func = None
|
||||
if args.filter_dataset_after_sampling:
|
||||
# Pass filter as callback to apply after sampling
|
||||
def filter_func(df):
|
||||
return filter_dataset(df, 'instance_id')
|
||||
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks (filtering will occur after sampling)'
|
||||
)
|
||||
else:
|
||||
# Apply filter before sampling
|
||||
swe_bench_tests = filter_dataset(swe_bench_tests, 'instance_id')
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
|
||||
)
|
||||
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
@ -810,7 +833,9 @@ if __name__ == '__main__':
|
||||
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
print(f'### OUTPUT FILE: {output_file} ###')
|
||||
instances = prepare_dataset(swe_bench_tests, output_file, args.eval_n_limit)
|
||||
instances = prepare_dataset(
|
||||
swe_bench_tests, output_file, args.eval_n_limit, filter_func=filter_func
|
||||
)
|
||||
|
||||
if len(instances) > 0 and not isinstance(
|
||||
instances['FAIL_TO_PASS'][instances['FAIL_TO_PASS'].index[0]], str
|
||||
|
||||
@ -8,8 +8,14 @@
|
||||
MODEL=$1 # eg your llm config name in config.toml (eg: "llm.claude-3-5-sonnet-20241022-t05")
|
||||
EXP_NAME=$2 # "train-t05"
|
||||
EVAL_DATASET=$3 # path to original dataset (jsonl file)
|
||||
N_WORKERS=${4:-64}
|
||||
N_RUNS=${5:-1}
|
||||
MAX_ITER=$4
|
||||
N_WORKERS=${5:-64}
|
||||
N_RUNS=${6:-1}
|
||||
EVAL_LIMIT=${7:-}
|
||||
SKIP_IDS_THRESHOLD=$8
|
||||
SKIP_IDS_PATTERN=$9
|
||||
INPUT_SKIP_IDS=${10}
|
||||
FILTER_DATASET_AFTER_SAMPLING=${11:-}
|
||||
|
||||
export EXP_NAME=$EXP_NAME
|
||||
# use 2x resources for rollout since some codebases are pretty resource-intensive
|
||||
@ -17,6 +23,7 @@ export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
|
||||
echo "MODEL: $MODEL"
|
||||
echo "EXP_NAME: $EXP_NAME"
|
||||
echo "EVAL_DATASET: $EVAL_DATASET"
|
||||
echo "INPUT_SKIP_IDS: $INPUT_SKIP_IDS"
|
||||
# Generate DATASET path by adding _with_runtime_ before .jsonl extension
|
||||
DATASET="${EVAL_DATASET%.jsonl}_with_runtime_.jsonl" # path to converted dataset
|
||||
|
||||
@ -35,9 +42,6 @@ else
|
||||
export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
|
||||
fi
|
||||
|
||||
#EVAL_LIMIT=3000
|
||||
MAX_ITER=100
|
||||
|
||||
|
||||
# ===== Run inference =====
|
||||
source "evaluation/utils/version_control.sh"
|
||||
@ -69,17 +73,52 @@ function run_eval() {
|
||||
--dataset $DATASET \
|
||||
--split $SPLIT"
|
||||
|
||||
# Conditionally add filter flag
|
||||
if [ "$FILTER_DATASET_AFTER_SAMPLING" = "true" ]; then
|
||||
COMMAND="$COMMAND --filter_dataset_after_sampling"
|
||||
fi
|
||||
|
||||
echo "Running command: $COMMAND"
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
}
|
||||
|
||||
for run_idx in $(seq 1 $N_RUNS); do
|
||||
if [ -n "$SKIP_IDS_THRESHOLD" ]; then
|
||||
echo "Computing SKIP_IDS for run $run_idx..."
|
||||
SKIP_CMD="poetry run python evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py $SKIP_IDS_THRESHOLD"
|
||||
if [ -n "$SKIP_IDS_PATTERN" ]; then
|
||||
SKIP_CMD="$SKIP_CMD --pattern \"$SKIP_IDS_PATTERN\""
|
||||
fi
|
||||
COMPUTED_SKIP_IDS=$(eval $SKIP_CMD)
|
||||
SKIP_STATUS=$?
|
||||
if [ $SKIP_STATUS -ne 0 ]; then
|
||||
echo "ERROR: Skip IDs computation failed with exit code $SKIP_STATUS"
|
||||
exit $SKIP_STATUS
|
||||
fi
|
||||
echo "COMPUTED_SKIP_IDS: $COMPUTED_SKIP_IDS"
|
||||
else
|
||||
echo "SKIP_IDS_THRESHOLD not provided, skipping SKIP_IDS computation"
|
||||
COMPUTED_SKIP_IDS=""
|
||||
fi
|
||||
|
||||
# Concatenate COMPUTED_SKIP_IDS and INPUT_SKIP_IDS
|
||||
if [ -n "$COMPUTED_SKIP_IDS" ] && [ -n "$INPUT_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="${COMPUTED_SKIP_IDS},${INPUT_SKIP_IDS}"
|
||||
elif [ -n "$COMPUTED_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="$COMPUTED_SKIP_IDS"
|
||||
elif [ -n "$INPUT_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="$INPUT_SKIP_IDS"
|
||||
else
|
||||
unset SKIP_IDS
|
||||
fi
|
||||
|
||||
echo "FINAL SKIP_IDS: $SKIP_IDS"
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
echo "### Running inference... ###"
|
||||
|
||||
@ -9,7 +9,7 @@ import time
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
from inspect import signature
|
||||
from typing import Any, Awaitable, Callable, TextIO
|
||||
from typing import Any, Awaitable, Callable, Optional, TextIO
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel
|
||||
@ -222,6 +222,7 @@ def prepare_dataset(
|
||||
eval_n_limit: int,
|
||||
eval_ids: list[str] | None = None,
|
||||
skip_num: int | None = None,
|
||||
filter_func: Optional[Callable[[pd.DataFrame], pd.DataFrame]] = None,
|
||||
):
|
||||
assert 'instance_id' in dataset.columns, (
|
||||
"Expected 'instance_id' column in the dataset. You should define your own unique identifier for each instance and use it as the 'instance_id' column."
|
||||
@ -265,6 +266,12 @@ def prepare_dataset(
|
||||
f'Randomly sampling {eval_n_limit} unique instances with random seed 42.'
|
||||
)
|
||||
|
||||
if filter_func is not None:
|
||||
dataset = filter_func(dataset)
|
||||
logger.info(
|
||||
f'Applied filter after sampling: {len(dataset)} instances remaining'
|
||||
)
|
||||
|
||||
def make_serializable(instance_dict: dict) -> dict:
|
||||
import numpy as np
|
||||
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("JupyterEditor", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the Zustand store before each test
|
||||
useJupyterStore.setState({
|
||||
cells: Array(20).fill({
|
||||
content: "Test cell content",
|
||||
type: "input",
|
||||
imageUrls: undefined,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should have a scrollable container", () => {
|
||||
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.RUNNING,
|
||||
});
|
||||
|
||||
render(
|
||||
<div style={{ height: "100vh" }}>
|
||||
<JupyterEditor maxWidth={800} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("jupyter-container");
|
||||
expect(container).toHaveClass("flex-1 overflow-y-auto");
|
||||
});
|
||||
});
|
||||
@ -11,6 +11,7 @@ const renderTerminal = (commands: Command[] = []) => {
|
||||
};
|
||||
|
||||
describe.skip("Terminal", () => {
|
||||
// Terminal is now read-only - no user input functionality
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
@ -21,8 +22,6 @@ describe.skip("Terminal", () => {
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
createMockMessageEvent,
|
||||
@ -13,8 +14,12 @@ import {
|
||||
OptimisticUserMessageStoreComponent,
|
||||
ErrorMessageStoreComponent,
|
||||
} from "./helpers/websocket-test-components";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import {
|
||||
ConversationWebSocketProvider,
|
||||
useConversationWebSocket,
|
||||
} from "#/contexts/conversation-websocket-context";
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
@ -417,7 +422,206 @@ describe("Conversation WebSocket Handler", () => {
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
|
||||
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
// 8. History Loading State Tests
|
||||
describe("History Loading State", () => {
|
||||
it("should track history loading state using event count from API", async () => {
|
||||
const conversationId = "test-conversation-with-history";
|
||||
|
||||
// Mock the event count API to return 3 events
|
||||
const expectedEventCount = 3;
|
||||
|
||||
// Create 3 mock events to simulate history
|
||||
const mockHistoryEvents = [
|
||||
createMockUserMessageEvent({ id: "history-event-1" }),
|
||||
createMockMessageEvent({ id: "history-event-2" }),
|
||||
createMockMessageEvent({ id: "history-event-3" }),
|
||||
];
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(expectedEventCount);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send all history events
|
||||
mockHistoryEvents.forEach((event) => {
|
||||
client.send(JSON.stringify(event));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
<div data-testid="events-received">{events.length}</div>
|
||||
<div data-testid="expected-event-count">{expectedEventCount}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-received")).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
// Once all events are received, loading should be complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty conversation history", async () => {
|
||||
const conversationId = "test-conversation-empty";
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(0);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ server }) => {
|
||||
server.connect();
|
||||
// No events sent for empty history
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Should quickly transition from loading to not loading when count is 0
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle history loading with large event count", async () => {
|
||||
const conversationId = "test-conversation-large-history";
|
||||
|
||||
// Create 50 mock events to simulate large history
|
||||
const expectedEventCount = 50;
|
||||
const mockHistoryEvents = Array.from({ length: 50 }, (_, i) =>
|
||||
createMockMessageEvent({ id: `history-event-${i + 1}` }),
|
||||
);
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(expectedEventCount);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send all history events
|
||||
mockHistoryEvents.forEach((event) => {
|
||||
client.send(JSON.stringify(event));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
<div data-testid="events-received">{events.length}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-received")).toHaveTextContent("50");
|
||||
});
|
||||
|
||||
// Once all events are received, loading should be complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
|
||||
@ -38,8 +38,7 @@ export const createWebSocketTestSetup = (
|
||||
/**
|
||||
* Standard WebSocket test setup for conversation WebSocket handler tests
|
||||
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
|
||||
* Uses a wildcard pattern to match any conversation ID
|
||||
*/
|
||||
export const conversationWebSocketTestSetup = () =>
|
||||
createWebSocketTestSetup(
|
||||
"ws://localhost:3000/sockets/events/test-conversation-default",
|
||||
);
|
||||
createWebSocketTestSetup("ws://localhost:3000/sockets/events/*");
|
||||
|
||||
@ -35,13 +35,12 @@ function TestTerminalComponent() {
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
// Terminal is read-only - no longer tests user input functionality
|
||||
const mockTerminal = vi.hoisted(() => ({
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import { ActionMessage } from "#/types/message";
|
||||
// Mock the store and actions
|
||||
const mockDispatch = vi.fn();
|
||||
const mockAppendInput = vi.fn();
|
||||
const mockAppendJupyterInput = vi.fn();
|
||||
|
||||
vi.mock("#/store", () => ({
|
||||
default: {
|
||||
@ -21,14 +20,6 @@ vi.mock("#/state/command-store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/jupyter-store", () => ({
|
||||
useJupyterStore: {
|
||||
getState: () => ({
|
||||
appendJupyterInput: mockAppendJupyterInput,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/metrics-slice", () => ({
|
||||
setMetrics: vi.fn(),
|
||||
}));
|
||||
@ -63,10 +54,9 @@ describe("handleActionMessage", () => {
|
||||
// Check that appendInput was called with the command
|
||||
expect(mockAppendInput).toHaveBeenCalledWith("ls -la");
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => {
|
||||
it("should handle RUN_IPYTHON actions as no-op (Jupyter removed)", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const ipythonAction: ActionMessage = {
|
||||
@ -84,10 +74,7 @@ describe("handleActionMessage", () => {
|
||||
// Handle the action
|
||||
handleActionMessage(ipythonAction);
|
||||
|
||||
// Check that appendJupyterInput was called with the code
|
||||
expect(mockAppendJupyterInput).toHaveBeenCalledWith(
|
||||
"print('Hello from Jupyter!')",
|
||||
);
|
||||
// Jupyter functionality has been removed, so nothing should be called
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -112,6 +99,5 @@ describe("handleActionMessage", () => {
|
||||
// Check that nothing was dispatched or called
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.60.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.60.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.60.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@ -187,7 +187,7 @@ class ConversationService {
|
||||
static async getRuntimeId(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const url = `/api/conversations/${conversationId}/config`;
|
||||
const url = `${this.getConversationUrl(conversationId)}/config`;
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(url, {
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
V1SendMessageRequest,
|
||||
V1SendMessageResponse,
|
||||
@ -10,24 +11,10 @@ import type {
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
V1SandboxInfo,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
private static buildSessionHeaders(
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
@ -160,7 +147,7 @@ class V1ConversationService {
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
||||
// Map it to match the expected interface
|
||||
@ -188,7 +175,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/pause`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
@ -216,7 +203,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/run`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
@ -282,6 +269,32 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 sandboxes by their IDs
|
||||
* Returns null for any missing sandboxes
|
||||
*
|
||||
* @param ids Array of sandbox IDs (max 100)
|
||||
* @returns Array of sandboxes or null for missing ones
|
||||
*/
|
||||
static async batchGetSandboxes(
|
||||
ids: string[],
|
||||
): Promise<(V1SandboxInfo | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 sandboxes at once");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("id", id));
|
||||
|
||||
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
|
||||
`/api/v1/sandboxes?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single file to the V1 conversation workspace
|
||||
* V1 API endpoint: POST /api/file/upload/{path}
|
||||
@ -305,7 +318,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/file/upload/${encodedPath}`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Create FormData with the file
|
||||
const formData = new FormData();
|
||||
@ -319,6 +332,37 @@ class V1ConversationService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation config (runtime_id) for a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns Object containing runtime_id
|
||||
*/
|
||||
static async getConversationConfig(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const url = `/api/conversations/${conversationId}/config`;
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of events for a conversation
|
||||
* Uses the V1 API endpoint: GET /api/v1/events/count
|
||||
*
|
||||
* @param conversationId The conversation ID to get event count for
|
||||
* @returns The number of events in the conversation
|
||||
*/
|
||||
static async getEventCount(conversationId: string): Promise<number> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("conversation_id__eq", conversationId);
|
||||
|
||||
const { data } = await openHands.get<number>(
|
||||
`/api/v1/events/count?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
|
||||
@ -98,3 +98,18 @@ export interface V1AppConversation {
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
}
|
||||
|
||||
export interface V1ExposedUrl {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface V1SandboxInfo {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
sandbox_spec_id: string;
|
||||
status: V1SandboxStatus;
|
||||
session_api_key: string | null;
|
||||
exposed_urls: V1ExposedUrl[] | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
41
frontend/src/api/event-service/event-service.api.ts
Normal file
41
frontend/src/api/event-service/event-service.api.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import axios from "axios";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
ConfirmationResponseRequest,
|
||||
ConfirmationResponseResponse,
|
||||
} from "./event-service.types";
|
||||
|
||||
class EventService {
|
||||
/**
|
||||
* Respond to a confirmation request in a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param request The confirmation response request
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns The confirmation response
|
||||
*/
|
||||
static async respondToConfirmation(
|
||||
conversationId: string,
|
||||
conversationUrl: string,
|
||||
request: ConfirmationResponseRequest,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<ConfirmationResponseResponse> {
|
||||
// Build the runtime URL using the conversation URL
|
||||
const runtimeUrl = buildHttpBaseUrl(conversationUrl);
|
||||
|
||||
// Build session headers for authentication
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Make the API call to the runtime endpoint
|
||||
const { data } = await axios.post<ConfirmationResponseResponse>(
|
||||
`${runtimeUrl}/api/conversations/${conversationId}/events/respond_to_confirmation`,
|
||||
request,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default EventService;
|
||||
8
frontend/src/api/event-service/event-service.types.ts
Normal file
8
frontend/src/api/event-service/event-service.types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface ConfirmationResponseRequest {
|
||||
accept: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmationResponseResponse {
|
||||
success: boolean;
|
||||
}
|
||||
@ -48,6 +48,7 @@ import {
|
||||
} from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@ -64,6 +65,7 @@ export function ChatInterface() {
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { isLoadingMessages } = useWsClient();
|
||||
const { isTask } = useTaskPolling();
|
||||
const conversationWebSocket = useConversationWebSocket();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
@ -94,6 +96,25 @@ export function ChatInterface() {
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Instantly scroll to bottom when history loading completes
|
||||
const prevLoadingHistoryRef = React.useRef(
|
||||
conversationWebSocket?.isLoadingHistory,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const wasLoading = prevLoadingHistoryRef.current;
|
||||
const isLoading = conversationWebSocket?.isLoadingHistory;
|
||||
|
||||
// When history loading transitions from true to false, instantly scroll to bottom
|
||||
if (wasLoading && !isLoading && scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
|
||||
prevLoadingHistoryRef.current = isLoading;
|
||||
}, [conversationWebSocket?.isLoadingHistory, scrollRef]);
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
.filter(isV0Event)
|
||||
@ -228,6 +249,14 @@ export function ChatInterface() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conversationWebSocket?.isLoadingHistory &&
|
||||
isV1Conversation &&
|
||||
!isTask && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
@ -237,13 +266,8 @@ export function ChatInterface() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{v1UserEventsExist && (
|
||||
<V1Messages
|
||||
messages={v1Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
{!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
|
||||
<V1Messages messages={v1Events} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -12,7 +12,6 @@ import { useConversationStore } from "#/state/conversation-store";
|
||||
// Lazy load all tab components
|
||||
const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
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"));
|
||||
|
||||
@ -24,7 +23,6 @@ export function ConversationTabContent() {
|
||||
// Determine which tab is active based on the current path
|
||||
const isEditorActive = selectedTab === "editor";
|
||||
const isBrowserActive = selectedTab === "browser";
|
||||
const isJupyterActive = selectedTab === "jupyter";
|
||||
const isServedActive = selectedTab === "served";
|
||||
const isVSCodeActive = selectedTab === "vscode";
|
||||
const isTerminalActive = selectedTab === "terminal";
|
||||
@ -37,11 +35,6 @@ export function ConversationTabContent() {
|
||||
component: BrowserTab,
|
||||
isActive: isBrowserActive,
|
||||
},
|
||||
{
|
||||
key: "jupyter",
|
||||
component: JupyterTab,
|
||||
isActive: isJupyterActive,
|
||||
},
|
||||
{ key: "served", component: ServedTab, isActive: isServedActive },
|
||||
{ key: "vscode", component: VSCodeTab, isActive: isVSCodeActive },
|
||||
{
|
||||
@ -58,9 +51,6 @@ export function ConversationTabContent() {
|
||||
if (isBrowserActive) {
|
||||
return t(I18nKey.COMMON$BROWSER);
|
||||
}
|
||||
if (isJupyterActive) {
|
||||
return t(I18nKey.COMMON$JUPYTER);
|
||||
}
|
||||
if (isServedActive) {
|
||||
return t(I18nKey.COMMON$APP);
|
||||
}
|
||||
@ -74,7 +64,6 @@ export function ConversationTabContent() {
|
||||
}, [
|
||||
isEditorActive,
|
||||
isBrowserActive,
|
||||
isJupyterActive,
|
||||
isServedActive,
|
||||
isVSCodeActive,
|
||||
isTerminalActive,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import JupyterIcon from "#/icons/jupyter.svg?react";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
@ -108,13 +107,6 @@ export function ConversationTabs() {
|
||||
tooltipContent: t(I18nKey.COMMON$TERMINAL),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL),
|
||||
},
|
||||
{
|
||||
isActive: isTabActive("jupyter"),
|
||||
icon: JupyterIcon,
|
||||
onClick: () => onTabSelected("jupyter"),
|
||||
tooltipContent: t(I18nKey.COMMON$JUPYTER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$JUPYTER),
|
||||
},
|
||||
{
|
||||
isActive: isTabActive("served"),
|
||||
icon: ServerIcon,
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
|
||||
interface JupytrerCellInputProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function JupytrerCellInput({ code }: JupytrerCellInputProps) {
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
|
||||
<div className="mb-1 text-gray-400">EXECUTE</div>
|
||||
<pre
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
<SyntaxHighlighter language="python" style={atomOneDark} wrapLongLines>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
import Markdown from "react-markdown";
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { JupyterLine } from "#/utils/parse-cell-content";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
|
||||
interface JupyterCellOutputProps {
|
||||
lines: JupyterLine[];
|
||||
}
|
||||
|
||||
export function JupyterCellOutput({ lines }: JupyterCellOutputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
|
||||
<div className="mb-1 text-gray-400">
|
||||
{t(I18nKey.JUPYTER$OUTPUT_LABEL)}
|
||||
</div>
|
||||
<pre
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5 max-h-[60vh] bg-gray-800"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{/* display the lines as plaintext or image */}
|
||||
{lines.map((line, index) => {
|
||||
if (line.type === "image") {
|
||||
// Use markdown to display the image
|
||||
const imageMarkdown = line.url
|
||||
? ``
|
||||
: line.content;
|
||||
return (
|
||||
<div key={index}>
|
||||
<Markdown
|
||||
components={{
|
||||
p: paragraph,
|
||||
}}
|
||||
urlTransform={(value: string) => value}
|
||||
>
|
||||
{imageMarkdown}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={index}>
|
||||
<SyntaxHighlighter language="plaintext" style={atomOneDark}>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
import { Cell } from "#/state/jupyter-store";
|
||||
import { JupyterLine, parseCellContent } from "#/utils/parse-cell-content";
|
||||
import { JupytrerCellInput } from "./jupyter-cell-input";
|
||||
import { JupyterCellOutput } from "./jupyter-cell-output";
|
||||
|
||||
interface JupyterCellProps {
|
||||
cell: Cell;
|
||||
}
|
||||
|
||||
export function JupyterCell({ cell }: JupyterCellProps) {
|
||||
const [lines, setLines] = React.useState<JupyterLine[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLines(parseCellContent(cell.content, cell.imageUrls));
|
||||
}, [cell.content, cell.imageUrls]);
|
||||
|
||||
if (cell.type === "input") {
|
||||
return <JupytrerCellInput code={cell.content} />;
|
||||
}
|
||||
|
||||
return <JupyterCellOutput lines={lines} />;
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { JupyterCell } from "./jupyter-cell";
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
interface JupyterEditorProps {
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const cells = useJupyterStore((state) => state.cells);
|
||||
|
||||
const jupyterRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const { hitBottom, scrollDomToBottom, onChatBodyScroll } =
|
||||
useScrollToBottom(jupyterRef);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRuntimeInactive && <WaitingForRuntimeMessage />}
|
||||
{!isRuntimeInactive && cells.length > 0 && (
|
||||
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
|
||||
<div
|
||||
data-testid="jupyter-container"
|
||||
className="flex-1 overflow-y-auto fast-smooth-scroll custom-scrollbar-always rounded-xl"
|
||||
ref={jupyterRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<JupyterCell key={index} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
{!hitBottom && (
|
||||
<div className="sticky bottom-2 flex items-center justify-center">
|
||||
<ScrollToBottomButton onClick={scrollDomToBottom} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isRuntimeInactive && cells.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-10 gap-4">
|
||||
<JupyterLargeIcon width={113} height={113} color="#A1A1A1" />
|
||||
<span className="text-[#8D95A9] text-[19px] font-normal leading-5">
|
||||
{t(I18nKey.COMMON$JUPYTER_EMPTY_MESSAGE)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ActionTooltip } from "../action-tooltip";
|
||||
import { RiskAlert } from "#/components/shared/risk-alert";
|
||||
import WarningIcon from "#/icons/u-warning.svg?react";
|
||||
import { useEventMessageStore } from "#/stores/event-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV1Event, isActionEvent } from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useRespondToConfirmation } from "#/hooks/mutation/use-respond-to-confirmation";
|
||||
import { SecurityRisk } from "#/types/v1/core/base/common";
|
||||
|
||||
export function V1ConfirmationButtons() {
|
||||
const v1SubmittedEventIds = useEventMessageStore(
|
||||
(state) => state.v1SubmittedEventIds,
|
||||
);
|
||||
const addV1SubmittedEventId = useEventMessageStore(
|
||||
(state) => state.addV1SubmittedEventId,
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { mutate: respondToConfirmation } = useRespondToConfirmation();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
// Find the most recent V1 action awaiting confirmation
|
||||
const awaitingAction = events
|
||||
.filter(isV1Event)
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((ev) => {
|
||||
if (ev.source !== "agent") return false;
|
||||
// For V1, we check if the agent state is waiting for confirmation
|
||||
return curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
|
||||
});
|
||||
|
||||
const handleConfirmation = useCallback(
|
||||
(accept: boolean) => {
|
||||
if (!awaitingAction || !conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark event as submitted to prevent duplicate submissions
|
||||
addV1SubmittedEventId(awaitingAction.id);
|
||||
|
||||
// Call the V1 API endpoint
|
||||
respondToConfirmation({
|
||||
conversationId: conversation.conversation_id,
|
||||
conversationUrl: conversation.url || "",
|
||||
sessionApiKey: conversation.session_api_key,
|
||||
accept,
|
||||
});
|
||||
},
|
||||
[
|
||||
awaitingAction,
|
||||
conversation,
|
||||
addV1SubmittedEventId,
|
||||
respondToConfirmation,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!awaitingAction) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleCancelShortcut = (event: KeyboardEvent) => {
|
||||
if (event.shiftKey && event.metaKey && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
handleConfirmation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueShortcut = (event: KeyboardEvent) => {
|
||||
if (event.metaKey && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleConfirmation(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Cancel: Shift+Cmd+Backspace (⇧⌘⌫)
|
||||
handleCancelShortcut(event);
|
||||
// Continue: Cmd+Enter (⌘↩)
|
||||
handleContinueShortcut(event);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [awaitingAction, handleConfirmation]);
|
||||
|
||||
// Only show if agent is waiting for confirmation and we haven't already submitted
|
||||
if (
|
||||
curAgentState !== AgentState.AWAITING_USER_CONFIRMATION ||
|
||||
!awaitingAction ||
|
||||
v1SubmittedEventIds.includes(awaitingAction.id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get security risk from the action (only ActionEvent has security_risk)
|
||||
const risk = isActionEvent(awaitingAction)
|
||||
? awaitingAction.security_risk
|
||||
: SecurityRisk.UNKNOWN;
|
||||
|
||||
const isHighRisk = risk === SecurityRisk.HIGH;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{isHighRisk && (
|
||||
<RiskAlert
|
||||
content={t(I18nKey.CHAT_INTERFACE$HIGH_RISK_WARNING)}
|
||||
icon={<WarningIcon width={16} height={16} color="#fff" />}
|
||||
severity="high"
|
||||
title={t(I18nKey.COMMON$HIGH_RISK)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm font-normal text-white">
|
||||
{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionTooltip
|
||||
type="reject"
|
||||
onClick={() => handleConfirmation(false)}
|
||||
/>
|
||||
<ActionTooltip
|
||||
type="confirm"
|
||||
onClick={() => handleConfirmation(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -19,6 +19,10 @@ const getFileEditorObservationContent = (
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
if (observation.error) {
|
||||
return `**Error:**\n${observation.error}`;
|
||||
}
|
||||
|
||||
const successMessage = getObservationResult(event) === "success";
|
||||
|
||||
// For view commands or successful edits with content changes, format as code block
|
||||
|
||||
@ -11,9 +11,10 @@ export const getObservationResult = (
|
||||
switch (observationType) {
|
||||
case "ExecuteBashObservation": {
|
||||
const exitCode = observation.exit_code;
|
||||
const { metadata } = observation;
|
||||
|
||||
if (exitCode === -1) return "timeout"; // Command timed out
|
||||
if (exitCode === 0) return "success"; // Command executed successfully
|
||||
if (exitCode === -1 || metadata.exit_code === -1) return "timeout"; // Command timed out
|
||||
if (exitCode === 0 || metadata.exit_code === 0) return "success"; // Command executed successfully
|
||||
return "error"; // Command failed
|
||||
}
|
||||
case "FileEditorObservation":
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
|
||||
interface GenericEventMessageWrapperProps {
|
||||
event: OpenHandsEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
export function GenericEventMessageWrapper({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
isLastMessage,
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
@ -27,7 +26,7 @@ export function GenericEventMessageWrapper({
|
||||
}
|
||||
initiallyExpanded={false}
|
||||
/>
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { ImageCarousel } from "../../../features/images/image-carousel";
|
||||
// TODO: Implement file_urls support for V1 messages
|
||||
// import { FileList } from "../../../features/files/file-list";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
@ -13,7 +13,6 @@ import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface UserAssistantEventMessageProps {
|
||||
event: MessageEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
@ -22,15 +21,16 @@ interface UserAssistantEventMessageProps {
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
export function UserAssistantEventMessage({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isLastMessage,
|
||||
}: UserAssistantEventMessageProps) {
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
@ -51,7 +51,7 @@ export function UserAssistantEventMessage({
|
||||
<ImageCarousel size="small" images={imageUrls} />
|
||||
)}
|
||||
{/* TODO: Handle file_urls if V1 messages support them */}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
|
||||
@ -21,7 +21,6 @@ import {
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
@ -38,7 +37,6 @@ interface EventMessageProps {
|
||||
export function EventMessage({
|
||||
event,
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
@ -46,9 +44,6 @@ export function EventMessage({
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// V1 events use string IDs, but useFeedbackExists expects number
|
||||
@ -103,17 +98,14 @@ export function EventMessage({
|
||||
return (
|
||||
<UserAssistantEventMessage
|
||||
event={event as MessageEvent}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
{...commonProps}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback for all other events (including observation events)
|
||||
return (
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
/>
|
||||
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,11 +10,10 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
|
||||
|
||||
interface MessagesProps {
|
||||
messages: OpenHandsEvent[];
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
}
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
({ messages }) => {
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
@ -43,7 +42,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
key={message.id}
|
||||
event={message}
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
// Microagent props - not implemented yet for V1
|
||||
|
||||
@ -5,6 +5,7 @@ import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
|
||||
@ -27,6 +28,7 @@ import {
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V1_WebSocketConnectionState =
|
||||
@ -38,6 +40,7 @@ export type V1_WebSocketConnectionState =
|
||||
interface ConversationWebSocketContextType {
|
||||
connectionState: V1_WebSocketConnectionState;
|
||||
sendMessage: (message: V1SendMessageRequest) => Promise<void>;
|
||||
isLoadingHistory: boolean;
|
||||
}
|
||||
|
||||
const ConversationWebSocketContext = createContext<
|
||||
@ -67,6 +70,13 @@ export function ConversationWebSocketProvider({
|
||||
const { setAgentStatus } = useV1ConversationStateStore();
|
||||
const { appendInput, appendOutput } = useCommandStore();
|
||||
|
||||
// History loading state
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||
const [expectedEventCount, setExpectedEventCount] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const receivedEventCountRef = useRef(0);
|
||||
|
||||
// Build WebSocket URL from props
|
||||
// Only build URL if we have both conversationId and conversationUrl
|
||||
// This prevents connection attempts during task polling phase
|
||||
@ -78,16 +88,43 @@ export function ConversationWebSocketProvider({
|
||||
return buildWebSocketUrl(conversationId, conversationUrl);
|
||||
}, [conversationId, conversationUrl]);
|
||||
|
||||
// Reset hasConnected flag when conversation changes
|
||||
// Reset hasConnected flag and history loading state when conversation changes
|
||||
useEffect(() => {
|
||||
hasConnectedRef.current = false;
|
||||
setIsLoadingHistory(true);
|
||||
setExpectedEventCount(null);
|
||||
receivedEventCountRef.current = 0;
|
||||
}, [conversationId]);
|
||||
|
||||
// Check if we've received all events when expectedEventCount becomes available
|
||||
useEffect(() => {
|
||||
if (
|
||||
expectedEventCount !== null &&
|
||||
receivedEventCountRef.current >= expectedEventCount &&
|
||||
isLoadingHistory
|
||||
) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [expectedEventCount, isLoadingHistory]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(messageEvent: MessageEvent) => {
|
||||
try {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
|
||||
// Track received events for history loading (count ALL events from WebSocket)
|
||||
// Always count when loading, even if we don't have the expected count yet
|
||||
if (isLoadingHistory) {
|
||||
receivedEventCountRef.current += 1;
|
||||
|
||||
if (
|
||||
expectedEventCount !== null &&
|
||||
receivedEventCountRef.current >= expectedEventCount
|
||||
) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Use type guard to validate v1 event structure
|
||||
if (isV1Event(event)) {
|
||||
addEvent(event);
|
||||
@ -141,6 +178,8 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
[
|
||||
addEvent,
|
||||
isLoadingHistory,
|
||||
expectedEventCount,
|
||||
setErrorMessage,
|
||||
removeOptimisticUserMessage,
|
||||
queryClient,
|
||||
@ -164,10 +203,27 @@ export function ConversationWebSocketProvider({
|
||||
return {
|
||||
queryParams,
|
||||
reconnect: { enabled: true },
|
||||
onOpen: () => {
|
||||
onOpen: async () => {
|
||||
setConnectionState("OPEN");
|
||||
hasConnectedRef.current = true; // Mark that we've successfully connected
|
||||
removeErrorMessage(); // Clear any previous error messages on successful connection
|
||||
|
||||
// Fetch expected event count for history loading detection
|
||||
if (conversationId) {
|
||||
try {
|
||||
const count =
|
||||
await V1ConversationService.getEventCount(conversationId);
|
||||
setExpectedEventCount(count);
|
||||
|
||||
// If no events expected, mark as loaded immediately
|
||||
if (count === 0) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to marking as loaded to avoid infinite loading state
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose: (event: CloseEvent) => {
|
||||
setConnectionState("CLOSED");
|
||||
@ -188,7 +244,13 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
onMessage: handleMessage,
|
||||
};
|
||||
}, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
|
||||
}, [
|
||||
handleMessage,
|
||||
setErrorMessage,
|
||||
removeErrorMessage,
|
||||
sessionApiKey,
|
||||
conversationId,
|
||||
]);
|
||||
|
||||
// Only attempt WebSocket connection when we have a valid URL
|
||||
// This prevents connection attempts during task polling phase
|
||||
@ -246,8 +308,8 @@ export function ConversationWebSocketProvider({
|
||||
}, [socket, wsUrl]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ connectionState, sendMessage }),
|
||||
[connectionState, sendMessage],
|
||||
() => ({ connectionState, sendMessage, isLoadingHistory }),
|
||||
[connectionState, sendMessage, isLoadingHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
32
frontend/src/hooks/mutation/use-respond-to-confirmation.ts
Normal file
32
frontend/src/hooks/mutation/use-respond-to-confirmation.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
import type { ConfirmationResponseRequest } from "#/api/event-service/event-service.types";
|
||||
|
||||
interface UseRespondToConfirmationVariables {
|
||||
conversationId: string;
|
||||
conversationUrl: string;
|
||||
sessionApiKey?: string | null;
|
||||
accept: boolean;
|
||||
}
|
||||
|
||||
export const useRespondToConfirmation = () =>
|
||||
useMutation({
|
||||
mutationKey: ["respond-to-confirmation"],
|
||||
mutationFn: async ({
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
accept,
|
||||
}: UseRespondToConfirmationVariables) => {
|
||||
const request: ConfirmationResponseRequest = {
|
||||
accept,
|
||||
};
|
||||
|
||||
return EventService.respondToConfirmation(
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
request,
|
||||
sessionApiKey,
|
||||
);
|
||||
},
|
||||
});
|
||||
11
frontend/src/hooks/query/use-batch-app-conversations.ts
Normal file
11
frontend/src/hooks/query/use-batch-app-conversations.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
export const useBatchAppConversations = (ids: string[]) =>
|
||||
useQuery({
|
||||
queryKey: ["v1-batch-get-app-conversations", ids],
|
||||
queryFn: () => V1ConversationService.batchGetAppConversations(ids),
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
11
frontend/src/hooks/query/use-batch-sandboxes.ts
Normal file
11
frontend/src/hooks/query/use-batch-sandboxes.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
export const useBatchSandboxes = (ids: string[]) =>
|
||||
useQuery({
|
||||
queryKey: ["sandboxes", "batch", ids],
|
||||
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@ -2,14 +2,20 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useRuntimeIsReady } from "../use-runtime-is-ready";
|
||||
import { useActiveConversation } from "./use-active-conversation";
|
||||
|
||||
export const useConversationConfig = () => {
|
||||
/**
|
||||
* @deprecated This hook is for V0 conversations only. Use useUnifiedConversationConfig instead,
|
||||
* or useV1ConversationConfig once we fully migrate to V1.
|
||||
*/
|
||||
export const useV0ConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["conversation_config", conversationId],
|
||||
queryKey: ["v0_conversation_config", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return ConversationService.getRuntimeId(conversationId);
|
||||
@ -34,3 +40,80 @@ export const useConversationConfig = () => {
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useV1ConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["v1_conversation_config", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return V1ConversationService.getConversationConfig(conversationId);
|
||||
},
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
const { runtime_id: runtimeId } = query.data;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"Runtime ID: %c%s",
|
||||
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
|
||||
runtimeId,
|
||||
);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified hook that switches between V0 and V1 conversation config endpoints based on conversation version.
|
||||
*
|
||||
* @temporary This hook is temporary during the V0 to V1 migration period.
|
||||
* Once we fully migrate to V1, all code should use useV1ConversationConfig directly.
|
||||
*/
|
||||
export const useUnifiedConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["conversation_config", conversationId, isV1Conversation],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
if (isV1Conversation) {
|
||||
return V1ConversationService.getConversationConfig(conversationId);
|
||||
}
|
||||
return ConversationService.getRuntimeId(conversationId);
|
||||
},
|
||||
enabled: runtimeIsReady && !!conversationId && conversation !== undefined,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
const { runtime_id: runtimeId } = query.data;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"Runtime ID: %c%s",
|
||||
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
|
||||
runtimeId,
|
||||
);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
// Keep the old export name for backward compatibility (uses unified approach)
|
||||
export const useConversationConfig = useUnifiedConversationConfig;
|
||||
|
||||
99
frontend/src/hooks/query/use-unified-active-host.ts
Normal file
99
frontend/src/hooks/query/use-unified-active-host.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import React from "react";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useBatchSandboxes } from "./use-batch-sandboxes";
|
||||
import { useConversationConfig } from "./use-conversation-config";
|
||||
|
||||
/**
|
||||
* Unified hook to get active web host for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy getWebHosts API endpoint and polls them
|
||||
* - V1: Gets worker URLs from sandbox exposed_urls (WORKER_1, WORKER_2, etc.)
|
||||
*/
|
||||
export const useUnifiedActiveHost = () => {
|
||||
const [activeHost, setActiveHost] = React.useState<string | null>(null);
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: conversationConfig, isLoading: isLoadingConfig } =
|
||||
useConversationConfig();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
const sandboxId = conversationConfig?.runtime_id;
|
||||
|
||||
// Fetch sandbox data for V1 conversations
|
||||
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
|
||||
|
||||
// Get worker URLs from V1 sandbox or legacy web hosts from V0
|
||||
const { data, isLoading: hostsQueryLoading } = useQuery({
|
||||
queryKey: [conversationId, "unified", "hosts", isV1Conversation, sandboxId],
|
||||
queryFn: async () => {
|
||||
// V1: Get worker URLs from sandbox exposed_urls
|
||||
if (isV1Conversation) {
|
||||
if (
|
||||
!sandboxesQuery.data ||
|
||||
sandboxesQuery.data.length === 0 ||
|
||||
!sandboxesQuery.data[0]
|
||||
) {
|
||||
return { hosts: [] };
|
||||
}
|
||||
|
||||
const sandbox = sandboxesQuery.data[0];
|
||||
const workerUrls =
|
||||
sandbox.exposed_urls
|
||||
?.filter((url) => url.name.startsWith("WORKER_"))
|
||||
.map((url) => url.url) || [];
|
||||
|
||||
return { hosts: workerUrls };
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint
|
||||
const hosts = await ConversationService.getWebHosts(conversationId);
|
||||
return { hosts };
|
||||
},
|
||||
enabled:
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
(!isV1Conversation || !!sandboxesQuery.data),
|
||||
initialData: { hosts: [] },
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Poll all hosts to find which one is active
|
||||
const apps = useQueries({
|
||||
queries: data.hosts.map((host) => ({
|
||||
queryKey: [conversationId, "unified", "hosts", host],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
await axios.get(host);
|
||||
return host;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const appsData = apps.map((app) => app.data);
|
||||
|
||||
React.useEffect(() => {
|
||||
const successfulApp = appsData.find((app) => app);
|
||||
setActiveHost(successfulApp || "");
|
||||
}, [appsData]);
|
||||
|
||||
// Calculate overall loading state including dependent queries for V1
|
||||
const isLoading = isV1Conversation
|
||||
? isLoadingConfig || sandboxesQuery.isLoading || hostsQueryLoading
|
||||
: hostsQueryLoading;
|
||||
|
||||
return { activeHost, isLoading };
|
||||
};
|
||||
122
frontend/src/hooks/query/use-unified-vscode-url.ts
Normal file
122
frontend/src/hooks/query/use-unified-vscode-url.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useBatchAppConversations } from "./use-batch-app-conversations";
|
||||
import { useBatchSandboxes } from "./use-batch-sandboxes";
|
||||
|
||||
interface VSCodeUrlResult {
|
||||
url: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook to get VSCode URL for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy getVSCodeUrl API endpoint
|
||||
* - V1: Gets the VSCode URL from sandbox exposed_urls
|
||||
*/
|
||||
export const useUnifiedVSCodeUrl = () => {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Fetch V1 app conversation to get sandbox_id
|
||||
const appConversationsQuery = useBatchAppConversations(
|
||||
isV1Conversation && conversationId ? [conversationId] : [],
|
||||
);
|
||||
const appConversation = appConversationsQuery.data?.[0];
|
||||
const sandboxId = appConversation?.sandbox_id;
|
||||
|
||||
// Fetch sandbox data for V1 conversations
|
||||
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
|
||||
|
||||
const mainQuery = useQuery<VSCodeUrlResult>({
|
||||
queryKey: [
|
||||
"unified",
|
||||
"vscode_url",
|
||||
conversationId,
|
||||
isV1Conversation,
|
||||
sandboxId,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
// V1: Get VSCode URL from sandbox exposed_urls
|
||||
if (isV1Conversation) {
|
||||
if (
|
||||
!sandboxesQuery.data ||
|
||||
sandboxesQuery.data.length === 0 ||
|
||||
!sandboxesQuery.data[0]
|
||||
) {
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
}
|
||||
|
||||
const sandbox = sandboxesQuery.data[0];
|
||||
const vscodeUrl = sandbox.exposed_urls?.find(
|
||||
(url) => url.name === "VSCODE",
|
||||
);
|
||||
|
||||
if (!vscodeUrl) {
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: transformVSCodeUrl(vscodeUrl.url),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint
|
||||
const data = await ConversationService.getVSCodeUrl(conversationId);
|
||||
|
||||
if (data.vscode_url) {
|
||||
return {
|
||||
url: transformVSCodeUrl(data.vscode_url),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
},
|
||||
enabled:
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
(!isV1Conversation || !!sandboxesQuery.data),
|
||||
refetchOnMount: true,
|
||||
retry: 3,
|
||||
});
|
||||
|
||||
// Calculate overall loading state including dependent queries for V1
|
||||
const isLoading = isV1Conversation
|
||||
? appConversationsQuery.isLoading ||
|
||||
sandboxesQuery.isLoading ||
|
||||
mainQuery.isLoading
|
||||
: mainQuery.isLoading;
|
||||
|
||||
// Explicitly destructure to avoid excessive re-renders from spreading the entire query object
|
||||
return {
|
||||
data: mainQuery.data,
|
||||
error: mainQuery.error,
|
||||
isLoading,
|
||||
isError: mainQuery.isError,
|
||||
isSuccess: mainQuery.isSuccess,
|
||||
status: mainQuery.status,
|
||||
refetch: mainQuery.refetch,
|
||||
};
|
||||
};
|
||||
@ -2,11 +2,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import React from "react";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { getTerminalCommand } from "#/services/terminal-service";
|
||||
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
/*
|
||||
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
|
||||
@ -38,15 +34,11 @@ const renderCommand = (
|
||||
const persistentLastCommandIndex = { current: 0 };
|
||||
|
||||
export const useTerminal = () => {
|
||||
const { send } = useSendMessage();
|
||||
const { curAgentState } = useAgentState();
|
||||
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);
|
||||
const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference
|
||||
const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null);
|
||||
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const createTerminal = () =>
|
||||
new Terminal({
|
||||
@ -57,6 +49,7 @@ export const useTerminal = () => {
|
||||
fastScrollModifier: "alt",
|
||||
fastScrollSensitivity: 5,
|
||||
allowTransparency: true,
|
||||
disableStdin: true, // Make terminal read-only
|
||||
theme: {
|
||||
background: "transparent",
|
||||
},
|
||||
@ -65,55 +58,12 @@ export const useTerminal = () => {
|
||||
const initializeTerminal = () => {
|
||||
if (terminal.current) {
|
||||
if (fitAddon.current) terminal.current.loadAddon(fitAddon.current);
|
||||
if (ref.current) terminal.current.open(ref.current);
|
||||
}
|
||||
};
|
||||
|
||||
const copySelection = (selection: string) => {
|
||||
const clipboardItem = new ClipboardItem({
|
||||
"text/plain": new Blob([selection], { type: "text/plain" }),
|
||||
});
|
||||
|
||||
navigator.clipboard.write([clipboardItem]);
|
||||
};
|
||||
|
||||
const pasteSelection = (callback: (text: string) => void) => {
|
||||
navigator.clipboard.readText().then(callback);
|
||||
};
|
||||
|
||||
const pasteHandler = (event: KeyboardEvent, cb: (text: string) => void) => {
|
||||
const isControlOrMetaPressed =
|
||||
event.type === "keydown" && (event.ctrlKey || event.metaKey);
|
||||
|
||||
if (isControlOrMetaPressed) {
|
||||
if (event.code === "KeyV") {
|
||||
pasteSelection((text: string) => {
|
||||
terminal.current?.write(text);
|
||||
cb(text);
|
||||
});
|
||||
}
|
||||
|
||||
if (event.code === "KeyC") {
|
||||
const selection = terminal.current?.getSelection();
|
||||
if (selection) copySelection(selection);
|
||||
if (ref.current) {
|
||||
terminal.current.open(ref.current);
|
||||
// Hide cursor for read-only terminal using ANSI escape sequence
|
||||
terminal.current.write("\x1b[?25l");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleEnter = (command: string) => {
|
||||
terminal.current?.write("\r\n");
|
||||
// Don't write the command again as it will be added to the commands array
|
||||
// and rendered by the useEffect that watches commands
|
||||
send(getTerminalCommand(command));
|
||||
// Don't add the prompt here as it will be added when the command is processed
|
||||
// and the commands array is updated
|
||||
};
|
||||
|
||||
const handleBackspace = (command: string) => {
|
||||
terminal.current?.write("\b \b");
|
||||
return command.slice(0, -1);
|
||||
};
|
||||
|
||||
// Initialize terminal and handle cleanup
|
||||
@ -136,7 +86,7 @@ export const useTerminal = () => {
|
||||
}
|
||||
lastCommandIndex.current = commands.length;
|
||||
}
|
||||
terminal.current.write("$ ");
|
||||
// Don't show prompt in read-only terminal
|
||||
}
|
||||
|
||||
return () => {
|
||||
@ -150,19 +100,17 @@ export const useTerminal = () => {
|
||||
commands.length > 0 &&
|
||||
lastCommandIndex.current < commands.length
|
||||
) {
|
||||
let lastCommandType = "";
|
||||
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
|
||||
lastCommandType = commands[i].type;
|
||||
if (commands[i].type === "input") {
|
||||
terminal.current.write("$ ");
|
||||
}
|
||||
// Pass true for isUserInput to skip rendering user input commands
|
||||
// that have already been displayed as the user typed
|
||||
renderCommand(commands[i], terminal.current, false);
|
||||
}
|
||||
lastCommandIndex.current = commands.length;
|
||||
if (lastCommandType === "output") {
|
||||
terminal.current.write("$ ");
|
||||
}
|
||||
}
|
||||
}, [commands, disabled]);
|
||||
}, [commands]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
@ -180,60 +128,5 @@ export const useTerminal = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (terminal.current) {
|
||||
// Dispose of existing listeners if they exist
|
||||
if (keyEventDisposable.current) {
|
||||
keyEventDisposable.current.dispose();
|
||||
keyEventDisposable.current = null;
|
||||
}
|
||||
|
||||
let commandBuffer = "";
|
||||
|
||||
if (!disabled) {
|
||||
// Add new key event listener and store the disposable
|
||||
keyEventDisposable.current = terminal.current.onKey(
|
||||
({ key, domEvent }) => {
|
||||
if (domEvent.key === "Enter") {
|
||||
handleEnter(commandBuffer);
|
||||
commandBuffer = "";
|
||||
} else if (domEvent.key === "Backspace") {
|
||||
if (commandBuffer.length > 0) {
|
||||
commandBuffer = handleBackspace(commandBuffer);
|
||||
}
|
||||
} else {
|
||||
// Ignore paste event
|
||||
if (key.charCodeAt(0) === 22) {
|
||||
return;
|
||||
}
|
||||
commandBuffer += key;
|
||||
terminal.current?.write(key);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Add custom key handler and store the disposable
|
||||
terminal.current.attachCustomKeyEventHandler((event) =>
|
||||
pasteHandler(event, (text) => {
|
||||
commandBuffer += text;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Add a noop handler when disabled
|
||||
keyEventDisposable.current = terminal.current.onKey((e) => {
|
||||
e.domEvent.preventDefault();
|
||||
e.domEvent.stopPropagation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (keyEventDisposable.current) {
|
||||
keyEventDisposable.current.dispose();
|
||||
keyEventDisposable.current = null;
|
||||
}
|
||||
};
|
||||
}, [disabled]);
|
||||
|
||||
return ref;
|
||||
};
|
||||
|
||||
@ -930,4 +930,5 @@ export enum I18nKey {
|
||||
TOAST$STOPPING_CONVERSATION = "TOAST$STOPPING_CONVERSATION",
|
||||
TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION",
|
||||
TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED",
|
||||
AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION",
|
||||
}
|
||||
|
||||
@ -14320,20 +14320,20 @@
|
||||
"uk": "Зупинити сервер"
|
||||
},
|
||||
"COMMON$TERMINAL": {
|
||||
"en": "Terminal",
|
||||
"ja": "ターミナル",
|
||||
"zh-CN": "终端",
|
||||
"zh-TW": "終端機",
|
||||
"ko-KR": "터미널",
|
||||
"no": "Terminal",
|
||||
"it": "Terminale",
|
||||
"pt": "Terminal",
|
||||
"es": "Terminal",
|
||||
"ar": "الطرفية",
|
||||
"fr": "Terminal",
|
||||
"tr": "Terminal",
|
||||
"de": "Terminal",
|
||||
"uk": "Термінал"
|
||||
"en": "Terminal (read-only)",
|
||||
"ja": "ターミナル (読み取り専用)",
|
||||
"zh-CN": "终端(只读)",
|
||||
"zh-TW": "終端機(唯讀)",
|
||||
"ko-KR": "터미널 (읽기 전용)",
|
||||
"no": "Terminal (skrivebeskyttet)",
|
||||
"it": "Terminale (sola lettura)",
|
||||
"pt": "Terminal (somente leitura)",
|
||||
"es": "Terminal (solo lectura)",
|
||||
"ar": "الطرفية (للقراءة فقط)",
|
||||
"fr": "Terminal (lecture seule)",
|
||||
"tr": "Terminal (salt okunur)",
|
||||
"de": "Terminal (schreibgeschützt)",
|
||||
"uk": "Термінал (тільки читання)"
|
||||
},
|
||||
"COMMON$UNKNOWN": {
|
||||
"en": "Unknown",
|
||||
@ -14878,5 +14878,21 @@
|
||||
"tr": "Konuşma durduruldu",
|
||||
"de": "Konversation gestoppt",
|
||||
"uk": "Розмову зупинено"
|
||||
},
|
||||
"AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION": {
|
||||
"en": "Waiting for user confirmation",
|
||||
"ja": "ユーザーの確認を待っています",
|
||||
"zh-CN": "等待用户确认",
|
||||
"zh-TW": "等待使用者確認",
|
||||
"ko-KR": "사용자 확인 대기 중",
|
||||
"no": "Venter på brukerbekreftelse",
|
||||
"it": "In attesa di conferma dell'utente",
|
||||
"pt": "Aguardando confirmação do usuário",
|
||||
"es": "Esperando confirmación del usuario",
|
||||
"ar": "في انتظار تأكيد المستخدم",
|
||||
"fr": "En attente de la confirmation de l'utilisateur",
|
||||
"tr": "Kullanıcı onayı bekleniyor",
|
||||
"de": "Warte auf Benutzerbestätigung",
|
||||
"uk": "Очікується підтвердження користувача"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="113" viewBox="0 0 113 113" fill="none">
|
||||
<path d="M57.9521 83.6306C57.2282 83.6659 56.3772 83.6871 55.5226 83.6871C41.334 83.6871 28.5015 77.8853 19.2672 68.5204L19.2602 68.5134C22.177 76.7765 27.3821 83.6271 34.1162 88.4967L34.2362 88.5814C40.8185 93.3522 49.0569 96.216 57.9627 96.216C66.8685 96.216 75.1034 93.3557 81.8022 88.5038L81.6821 88.585C88.5327 83.6306 93.7378 76.7835 96.5663 68.81L96.6546 68.5204C87.4062 77.8888 74.5631 83.6907 60.3675 83.6907C59.5164 83.6907 58.6689 83.6695 57.8285 83.6271L57.9521 83.6306ZM57.9486 24.9624C58.676 24.9236 59.527 24.9024 60.3851 24.9024C74.5702 24.9024 87.4027 30.7043 96.6334 40.0656L96.6405 40.0727C93.7237 31.8095 88.5186 24.9589 81.788 20.0893L81.668 20.0046C75.0857 15.2374 66.8473 12.3806 57.9415 12.3806C49.0357 12.3806 40.8008 15.2374 34.0985 20.0893L34.2186 20.0081C27.3644 24.9589 22.1593 31.8095 19.3308 39.7831L19.2425 40.0727C28.5015 30.7078 41.3517 24.9059 55.5579 24.9059C56.3983 24.9059 57.2388 24.9271 58.0686 24.966L57.9486 24.9624ZM25.5776 18.2672C25.5776 19.549 25.0585 20.7108 24.2216 21.5548C23.3882 22.3952 22.2335 22.9178 20.9587 22.9178C19.6839 22.9178 18.5257 22.3952 17.6958 21.5548C16.8589 20.7108 16.3398 19.5455 16.3398 18.2637C16.3398 16.9818 16.8589 15.8165 17.6958 14.9725C18.5292 14.1321 19.6839 13.6095 20.9587 13.6095C22.2335 13.6095 23.3918 14.1321 24.2251 14.9761C25.062 15.82 25.5776 16.9818 25.5776 18.2672ZM94.3734 9.84516C94.3734 9.84869 94.3734 9.85222 94.3734 9.85222C94.3734 11.5861 93.6742 13.1575 92.5442 14.2981C91.4178 15.4387 89.8534 16.1449 88.1266 16.1449C86.3998 16.1449 84.8355 15.4387 83.709 14.3016C82.579 13.1575 81.8798 11.5825 81.8798 9.84516C81.8798 8.10779 82.579 6.53285 83.709 5.38872C84.8355 4.25166 86.3963 3.54541 88.1266 3.54541C89.8569 3.54541 91.4178 4.25166 92.5442 5.38872C93.6742 6.52932 94.3734 8.10072 94.3734 9.8381V9.84516ZM35.1296 101.516C35.1296 101.516 35.1296 101.52 35.1296 101.523C35.1296 103.709 34.2503 105.69 32.8237 107.131C31.4042 108.568 29.4337 109.458 27.2585 109.458C25.0832 109.458 23.1128 108.568 21.6932 107.135C20.2666 105.694 19.3873 103.709 19.3873 101.52C19.3873 99.3306 20.2666 97.3495 21.6932 95.9053C23.1128 94.468 25.0797 93.5817 27.2585 93.5817C29.4372 93.5817 31.4042 94.4716 32.8237 95.9088C34.2468 97.3495 35.1296 99.3306 35.1296 101.516C35.1296 101.52 35.1296 101.52 35.1296 101.523V101.516Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@ -1,9 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="scale(0.85) translate(1.8, 1.8)">
|
||||
<path d="M19.5 1.5C18.67 1.5 18 2.17 18 3C18 3.83 18.67 4.5 19.5 4.5C20.33 4.5 21 3.83 21 3C21 2.17 20.33 1.5 19.5 1.5Z" fill="currentColor"/>
|
||||
<path d="M12 18C8.5 18 5.5 16.8 4 15C4 18.3137 7.13401 21 12 21C16.866 21 20 18.3137 20 15C18.5 16.8 15.5 18 12 18Z" fill="currentColor"/>
|
||||
<path d="M12 6C15.5 6 18.5 7.2 20 9C20 5.68629 16.866 3 12 3C7.13401 3 4 5.68629 4 9C5.5 7.2 8.5 6 12 6Z" fill="currentColor"/>
|
||||
<path d="M7.5 21C6.67 21 6 21.67 6 22.5C6 23.33 6.67 24 7.5 24C8.33 24 9 23.33 9 22.5C9 21.67 8.33 21 7.5 21Z" fill="currentColor"/>
|
||||
<path d="M4.5 5.5C3.67 5.5 3 4.83 3 4C3 3.17 3.67 2.5 4.5 2.5C5.33 2.5 6 3.17 6 4C6 4.83 5.33 5.5 4.5 5.5Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 831 B |
@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
@ -53,7 +52,6 @@ function AppContent() {
|
||||
const setCurrentAgentState = useAgentStore(
|
||||
(state) => state.setCurrentAgentState,
|
||||
);
|
||||
const clearJupyter = useJupyterStore((state) => state.clearJupyter);
|
||||
const removeErrorMessage = useErrorMessageStore(
|
||||
(state) => state.removeErrorMessage,
|
||||
);
|
||||
@ -70,7 +68,6 @@ function AppContent() {
|
||||
// 1. Cleanup Effect - runs when navigating to a different conversation
|
||||
React.useEffect(() => {
|
||||
clearTerminal();
|
||||
clearJupyter();
|
||||
resetConversationState();
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
removeErrorMessage();
|
||||
@ -84,7 +81,6 @@ function AppContent() {
|
||||
}, [
|
||||
conversationId,
|
||||
clearTerminal,
|
||||
clearJupyter,
|
||||
resetConversationState,
|
||||
setCurrentAgentState,
|
||||
removeErrorMessage,
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
import React from "react";
|
||||
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
|
||||
|
||||
function Jupyter() {
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [parentWidth, setParentWidth] = React.useState(0);
|
||||
|
||||
// This is a hack to prevent the editor from overflowing
|
||||
// Should be removed after revising the parent and containers
|
||||
// Use ResizeObserver to properly track parent width changes
|
||||
React.useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
// Use contentRect.width for more accurate measurements
|
||||
const { width } = entry.contentRect;
|
||||
if (width > 0) {
|
||||
setParentWidth(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (parentRef.current) {
|
||||
resizeObserver.observe(parentRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Provide a fallback width to prevent the editor from being hidden
|
||||
// Use parentWidth if available, otherwise use a large default
|
||||
const maxWidth = parentWidth > 0 ? parentWidth : 9999;
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-full">
|
||||
<JupyterEditor maxWidth={maxWidth} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Jupyter;
|
||||
@ -2,14 +2,14 @@ import React from "react";
|
||||
import { FaArrowRotateRight } from "react-icons/fa6";
|
||||
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useActiveHost } from "#/hooks/query/use-active-host";
|
||||
import { useUnifiedActiveHost } from "#/hooks/query/use-unified-active-host";
|
||||
import { PathForm } from "#/components/features/served-host/path-form";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import ServerProcessIcon from "#/icons/server-process.svg?react";
|
||||
|
||||
function ServedApp() {
|
||||
const { t } = useTranslation();
|
||||
const { activeHost } = useActiveHost();
|
||||
const { activeHost } = useUnifiedActiveHost();
|
||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||
const [currentActiveHost, setCurrentActiveHost] = React.useState<
|
||||
string | null
|
||||
|
||||
@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
||||
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
|
||||
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
function VSCodeTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error } = useVSCodeUrl();
|
||||
const { data, isLoading, error } = useUnifiedVSCodeUrl();
|
||||
const { curAgentState } = useAgentState();
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||
@ -39,10 +39,18 @@ function VSCodeTab() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isRuntimeInactive || isLoading) {
|
||||
if (isRuntimeInactive) {
|
||||
return <WaitingForRuntimeMessage />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{t(I18nKey.VSCODE$LOADING)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || (data && data.error) || !data?.url || iframeError) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
StatusMessage,
|
||||
} from "#/types/message";
|
||||
import { handleObservationMessage } from "./observations";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import {
|
||||
@ -35,10 +34,6 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
useCommandStore.getState().appendInput(message.args.command);
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN_IPYTHON) {
|
||||
useJupyterStore.getState().appendJupyterInput(message.args.code);
|
||||
}
|
||||
|
||||
if ("args" in message && "security_risk" in message.args) {
|
||||
useSecurityAnalyzerStore.getState().appendSecurityAnalyzerInput({
|
||||
id: message.id,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { ObservationMessage } from "#/types/message";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import ObservationType from "#/types/observation-type";
|
||||
import { useBrowserStore } from "#/stores/browser-store";
|
||||
@ -22,14 +21,6 @@ export function handleObservationMessage(message: ObservationMessage) {
|
||||
useCommandStore.getState().appendOutput(content);
|
||||
break;
|
||||
}
|
||||
case ObservationType.RUN_IPYTHON:
|
||||
useJupyterStore.getState().appendJupyterOutput({
|
||||
content: message.content,
|
||||
imageUrls: Array.isArray(message.extras?.image_urls)
|
||||
? message.extras.image_urls
|
||||
: undefined,
|
||||
});
|
||||
break;
|
||||
case ObservationType.BROWSE:
|
||||
case ObservationType.BROWSE_INTERACTIVE:
|
||||
if (
|
||||
|
||||
@ -4,7 +4,6 @@ import { devtools } from "zustand/middleware";
|
||||
export type ConversationTab =
|
||||
| "editor"
|
||||
| "browser"
|
||||
| "jupyter"
|
||||
| "served"
|
||||
| "vscode"
|
||||
| "terminal";
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Cell = {
|
||||
content: string;
|
||||
type: "input" | "output";
|
||||
imageUrls?: string[];
|
||||
};
|
||||
|
||||
interface JupyterState {
|
||||
cells: Cell[];
|
||||
appendJupyterInput: (content: string) => void;
|
||||
appendJupyterOutput: (payload: {
|
||||
content: string;
|
||||
imageUrls?: string[];
|
||||
}) => void;
|
||||
clearJupyter: () => void;
|
||||
}
|
||||
|
||||
export const useJupyterStore = create<JupyterState>((set) => ({
|
||||
cells: [],
|
||||
appendJupyterInput: (content: string) =>
|
||||
set((state) => ({
|
||||
cells: [...state.cells, { content, type: "input" }],
|
||||
})),
|
||||
appendJupyterOutput: (payload: { content: string; imageUrls?: string[] }) =>
|
||||
set((state) => ({
|
||||
cells: [
|
||||
...state.cells,
|
||||
{
|
||||
content: payload.content,
|
||||
type: "output",
|
||||
imageUrls: payload.imageUrls,
|
||||
},
|
||||
],
|
||||
})),
|
||||
clearJupyter: () =>
|
||||
set(() => ({
|
||||
cells: [],
|
||||
})),
|
||||
}));
|
||||
@ -2,15 +2,19 @@ import { create } from "zustand";
|
||||
|
||||
interface EventMessageState {
|
||||
submittedEventIds: number[]; // Avoid the flashing issue of the confirmation buttons
|
||||
v1SubmittedEventIds: string[]; // V1 event IDs (V1 uses string IDs)
|
||||
}
|
||||
|
||||
interface EventMessageStore extends EventMessageState {
|
||||
addSubmittedEventId: (id: number) => void;
|
||||
removeSubmittedEventId: (id: number) => void;
|
||||
addV1SubmittedEventId: (id: string) => void;
|
||||
removeV1SubmittedEventId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useEventMessageStore = create<EventMessageStore>((set) => ({
|
||||
submittedEventIds: [],
|
||||
v1SubmittedEventIds: [],
|
||||
addSubmittedEventId: (id: number) =>
|
||||
set((state) => ({
|
||||
submittedEventIds: [...state.submittedEventIds, id],
|
||||
@ -21,4 +25,14 @@ export const useEventMessageStore = create<EventMessageStore>((set) => ({
|
||||
(eventId) => eventId !== id,
|
||||
),
|
||||
})),
|
||||
addV1SubmittedEventId: (id: string) =>
|
||||
set((state) => ({
|
||||
v1SubmittedEventIds: [...state.v1SubmittedEventIds, id],
|
||||
})),
|
||||
removeV1SubmittedEventId: (id: string) =>
|
||||
set((state) => ({
|
||||
v1SubmittedEventIds: state.v1SubmittedEventIds.filter(
|
||||
(eventId) => eventId !== id,
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
@ -1,21 +1,10 @@
|
||||
enum TabOption {
|
||||
PLANNER = "planner",
|
||||
BROWSER = "browser",
|
||||
JUPYTER = "jupyter",
|
||||
VSCODE = "vscode",
|
||||
}
|
||||
|
||||
type TabType =
|
||||
| TabOption.PLANNER
|
||||
| TabOption.BROWSER
|
||||
| TabOption.JUPYTER
|
||||
| TabOption.VSCODE;
|
||||
|
||||
const AllTabs = [
|
||||
TabOption.VSCODE,
|
||||
TabOption.BROWSER,
|
||||
TabOption.PLANNER,
|
||||
TabOption.JUPYTER,
|
||||
];
|
||||
type TabType = TabOption.PLANNER | TabOption.BROWSER | TabOption.VSCODE;
|
||||
const AllTabs = [TabOption.VSCODE, TabOption.BROWSER, TabOption.PLANNER];
|
||||
|
||||
export { AllTabs, TabOption, type TabType };
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
export type JupyterLine = {
|
||||
type: "plaintext" | "image";
|
||||
content: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export const parseCellContent = (content: string, imageUrls?: string[]) => {
|
||||
const lines: JupyterLine[] = [];
|
||||
let currentText = "";
|
||||
|
||||
// First, process the text content
|
||||
for (const line of content.split("\n")) {
|
||||
currentText += `${line}\n`;
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
lines.push({ type: "plaintext", content: currentText });
|
||||
}
|
||||
|
||||
// Then, add image lines if we have image URLs
|
||||
if (imageUrls && imageUrls.length > 0) {
|
||||
imageUrls.forEach((url) => {
|
||||
lines.push({
|
||||
type: "image",
|
||||
content: ``,
|
||||
url,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
@ -24,7 +24,7 @@ export const AGENT_STATUS_MAP: {
|
||||
// Ready/Idle/Waiting for user input states
|
||||
[AgentState.AWAITING_USER_INPUT]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
|
||||
[AgentState.AWAITING_USER_CONFIRMATION]:
|
||||
I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
|
||||
I18nKey.AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION,
|
||||
[AgentState.USER_CONFIRMED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
|
||||
[AgentState.USER_REJECTED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
|
||||
[AgentState.FINISHED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
|
||||
|
||||
@ -594,3 +594,18 @@ export const hasOpenHandsSuffix = (
|
||||
}
|
||||
return repo.full_name.endsWith("/.openhands");
|
||||
};
|
||||
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
export const buildSessionHeaders = (
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
@ -15,13 +15,12 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openhands_cli.llm_utils import get_llm_metadata
|
||||
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
|
||||
from openhands_cli.utils import get_llm_metadata, get_default_cli_agent
|
||||
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
|
||||
|
||||
from openhands.sdk import LLM
|
||||
from openhands.tools.preset.default import get_default_agent
|
||||
|
||||
dummy_agent = get_default_agent(
|
||||
dummy_agent = get_default_cli_agent(
|
||||
llm=LLM(
|
||||
model='dummy-model',
|
||||
api_key='dummy-key',
|
||||
|
||||
@ -120,6 +120,7 @@ class ConversationRunner:
|
||||
else:
|
||||
raise Exception('Infinite loop')
|
||||
|
||||
|
||||
def _handle_confirmation_request(self) -> UserConfirmation:
|
||||
"""Handle confirmation request from user.
|
||||
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import os
|
||||
|
||||
from openhands.sdk import LLM, BaseConversation, LocalFileStore
|
||||
from openhands.sdk.security.confirmation_policy import NeverConfirm
|
||||
from openhands.tools.preset.default import get_default_agent
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from prompt_toolkit.shortcuts import print_container
|
||||
from prompt_toolkit.widgets import Frame, TextArea
|
||||
|
||||
from openhands_cli.llm_utils import get_llm_metadata
|
||||
from openhands_cli.utils import get_llm_metadata, get_default_cli_agent
|
||||
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
|
||||
from openhands_cli.pt_style import COLOR_GREY
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
@ -182,7 +180,7 @@ class SettingsScreen:
|
||||
|
||||
agent = self.agent_store.load()
|
||||
if not agent:
|
||||
agent = get_default_agent(llm=llm, cli_mode=True)
|
||||
agent = get_default_cli_agent(llm=llm)
|
||||
|
||||
agent = agent.model_copy(update={'llm': llm})
|
||||
self.agent_store.save(agent)
|
||||
|
||||
@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.mcp_config import MCPConfig
|
||||
from openhands_cli.llm_utils import get_llm_metadata
|
||||
from openhands_cli.utils import get_llm_metadata
|
||||
from openhands_cli.locations import (
|
||||
AGENT_SETTINGS_PATH,
|
||||
MCP_CONFIG_FILE,
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
|
||||
from openhands.tools.preset import get_default_agent
|
||||
from openhands.sdk import LLM
|
||||
|
||||
def get_llm_metadata(
|
||||
model_name: str,
|
||||
@ -55,3 +57,20 @@ def get_llm_metadata(
|
||||
if user_id is not None:
|
||||
metadata['trace_user_id'] = user_id
|
||||
return metadata
|
||||
|
||||
|
||||
def get_default_cli_agent(
|
||||
llm: LLM
|
||||
):
|
||||
agent = get_default_agent(
|
||||
llm=llm,
|
||||
cli_mode=True
|
||||
)
|
||||
|
||||
agent = agent.model_copy(
|
||||
update={
|
||||
'security_analyzer': LLMSecurityAnalyzer()
|
||||
}
|
||||
)
|
||||
|
||||
return agent
|
||||
@ -18,8 +18,8 @@ classifiers = [
|
||||
# Using Git URLs for dependencies so installs from PyPI pull from GitHub
|
||||
# TODO: pin package versions once agent-sdk has published PyPI packages
|
||||
dependencies = [
|
||||
"openhands-sdk==1.0.0a3",
|
||||
"openhands-tools==1.0.0a3",
|
||||
"openhands-sdk==1.0.0a5",
|
||||
"openhands-tools==1.0.0a5",
|
||||
"prompt-toolkit>=3",
|
||||
"typer>=0.17.4",
|
||||
]
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
"""Test that first-time settings screen usage creates a default agent with security analyzer."""
|
||||
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from openhands_cli.user_actions.settings_action import SettingsType
|
||||
from openhands.sdk import LLM
|
||||
from pydantic import SecretStr
|
||||
|
||||
|
||||
def test_first_time_settings_creates_default_agent_with_security_analyzer():
|
||||
"""Test that using the settings screen for the first time creates a default agent with a non-None security analyzer."""
|
||||
|
||||
# Create a settings screen instance (no conversation initially)
|
||||
screen = SettingsScreen(conversation=None)
|
||||
|
||||
# Mock all the user interaction steps to simulate first-time setup
|
||||
with (
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.settings_type_confirmation',
|
||||
return_value=SettingsType.BASIC,
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.choose_llm_provider',
|
||||
return_value='openai',
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.choose_llm_model',
|
||||
return_value='gpt-4o-mini',
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.prompt_api_key',
|
||||
return_value='sk-test-key-123',
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.save_settings_confirmation',
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
# Run the settings configuration workflow
|
||||
screen.configure_settings(first_time=True)
|
||||
|
||||
# Load the saved agent from the store
|
||||
saved_agent = screen.agent_store.load()
|
||||
|
||||
# Verify that an agent was created and saved
|
||||
assert saved_agent is not None, "Agent should be created and saved after first-time settings configuration"
|
||||
|
||||
# Verify that the agent has the expected LLM configuration
|
||||
assert saved_agent.llm.model == 'openai/gpt-4o-mini', f"Expected model 'openai/gpt-4o-mini', got '{saved_agent.llm.model}'"
|
||||
assert saved_agent.llm.api_key.get_secret_value() == 'sk-test-key-123', "API key should match the provided value"
|
||||
|
||||
# Verify that the agent has a security analyzer and it's not None
|
||||
assert hasattr(saved_agent, 'security_analyzer'), "Agent should have a security_analyzer attribute"
|
||||
assert saved_agent.security_analyzer is not None, "Security analyzer should not be None"
|
||||
|
||||
# Verify the security analyzer has the expected type/kind
|
||||
assert hasattr(saved_agent.security_analyzer, 'kind'), "Security analyzer should have a 'kind' attribute"
|
||||
assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{saved_agent.security_analyzer.kind}'"
|
||||
|
||||
|
||||
def test_first_time_settings_with_advanced_configuration():
|
||||
"""Test that advanced settings also create a default agent with security analyzer."""
|
||||
|
||||
screen = SettingsScreen(conversation=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.settings_type_confirmation',
|
||||
return_value=SettingsType.ADVANCED,
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.prompt_custom_model',
|
||||
return_value='anthropic/claude-3-5-sonnet',
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.prompt_base_url',
|
||||
return_value='https://api.anthropic.com',
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.prompt_api_key',
|
||||
return_value='sk-ant-test-key',
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.choose_memory_condensation',
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
'openhands_cli.tui.settings.settings_screen.save_settings_confirmation',
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
screen.configure_settings(first_time=True)
|
||||
|
||||
saved_agent = screen.agent_store.load()
|
||||
|
||||
# Verify agent creation and security analyzer
|
||||
assert saved_agent is not None, "Agent should be created with advanced settings"
|
||||
assert saved_agent.security_analyzer is not None, "Security analyzer should not be None in advanced settings"
|
||||
assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer"
|
||||
|
||||
# Verify advanced settings were applied
|
||||
assert saved_agent.llm.model == 'anthropic/claude-3-5-sonnet', "Custom model should be set"
|
||||
assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set"
|
||||
@ -6,10 +6,10 @@ import pytest
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
from openhands_cli.user_actions.settings_action import SettingsType
|
||||
from openhands_cli.utils import get_default_cli_agent
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.sdk import LLM, Conversation, LocalFileStore
|
||||
from openhands.tools.preset.default import get_default_agent
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
@ -30,7 +30,7 @@ def make_screen_with_conversation(model='openai/gpt-4o-mini', api_key='sk-xyz'):
|
||||
def seed_file(path: Path, model: str = 'openai/gpt-4o-mini', api_key: str = 'sk-old'):
|
||||
store = AgentStore()
|
||||
store.file_store = LocalFileStore(root=str(path))
|
||||
agent = get_default_agent(
|
||||
agent = get_default_cli_agent(
|
||||
llm=LLM(model=model, api_key=SecretStr(api_key), service_id='test-service')
|
||||
)
|
||||
store.save(agent)
|
||||
|
||||
@ -6,13 +6,13 @@ from openhands_cli.runner import ConversationRunner
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
from pydantic import ConfigDict, SecretStr, model_validator
|
||||
|
||||
from openhands.sdk import Conversation, ConversationCallbackType
|
||||
from openhands.sdk import Conversation, ConversationCallbackType, LocalConversation
|
||||
from openhands.sdk.agent.base import AgentBase
|
||||
from openhands.sdk.conversation import ConversationState
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from openhands.sdk.llm import LLM
|
||||
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
class FakeLLM(LLM):
|
||||
@model_validator(mode='after')
|
||||
@ -41,11 +41,11 @@ class FakeAgent(AgentBase):
|
||||
pass
|
||||
|
||||
def step(
|
||||
self, state: ConversationState, on_event: ConversationCallbackType
|
||||
self, conversation: LocalConversation, on_event: ConversationCallbackType
|
||||
) -> None:
|
||||
self.step_count += 1
|
||||
if self.step_count == self.finish_on_step:
|
||||
state.agent_status = AgentExecutionStatus.FINISHED
|
||||
conversation.state.agent_status = AgentExecutionStatus.FINISHED
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@ -102,15 +102,15 @@ class TestConversationRunner:
|
||||
"""
|
||||
if final_status == AgentExecutionStatus.FINISHED:
|
||||
agent.finish_on_step = 1
|
||||
|
||||
|
||||
# Add a mock security analyzer to enable confirmation mode
|
||||
from unittest.mock import MagicMock
|
||||
agent.security_analyzer = MagicMock()
|
||||
|
||||
|
||||
convo = Conversation(agent)
|
||||
convo.state.agent_status = AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
cr = ConversationRunner(convo)
|
||||
cr.set_confirmation_policy(AlwaysConfirm())
|
||||
|
||||
with patch.object(
|
||||
cr, '_handle_confirmation_request', return_value=confirmation
|
||||
) as mock_confirmation_request:
|
||||
|
||||
18
openhands-cli/uv.lock
generated
18
openhands-cli/uv.lock
generated
@ -1828,7 +1828,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openhands-sdk" },
|
||||
@ -1855,8 +1855,8 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "openhands-sdk", specifier = "==1.0.0a3" },
|
||||
{ name = "openhands-tools", specifier = "==1.0.0a3" },
|
||||
{ name = "openhands-sdk", specifier = "==1.0.0a5" },
|
||||
{ name = "openhands-tools", specifier = "==1.0.0a5" },
|
||||
{ name = "prompt-toolkit", specifier = ">=3" },
|
||||
{ name = "typer", specifier = ">=0.17.4" },
|
||||
]
|
||||
@ -1879,7 +1879,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0a3"
|
||||
version = "1.0.0a5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastmcp" },
|
||||
@ -1891,14 +1891,14 @@ dependencies = [
|
||||
{ name = "tenacity" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/82/33b3e3560e259803b773eee9cb377fce63b56c4252f3036126e225171926/openhands_sdk-1.0.0a3.tar.gz", hash = "sha256:c2cf6ab2ac105d257a31fde0e502a81faa969c7e64e0b2364d0634d2ce8e93b4", size = 144940, upload-time = "2025-10-20T15:38:39.647Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/90/d40f6716641a95a61d2042f00855e0eadc0b2558167078324576cc5a3c22/openhands_sdk-1.0.0a5.tar.gz", hash = "sha256:8888d6892d58cf9b11a71fa80086156c0b6c9a0b50df6839c0a9cafffba2338c", size = 152810, upload-time = "2025-10-29T16:19:52.086Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/ab/4464d2470ef1e02334f9ade094dfefa2cfc5bb761b201663a3e4121e1892/openhands_sdk-1.0.0a3-py3-none-any.whl", hash = "sha256:c8ab45160b67e7de391211ae5607ccfdf44e39781f74d115a2a22df35a2f4311", size = 191937, upload-time = "2025-10-20T15:38:38.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/6b/d3aa28019163f22f4b589ad818b83e3bea23d0a50b0c51ecc070ffdec139/openhands_sdk-1.0.0a5-py3-none-any.whl", hash = "sha256:db20272b04cf03627f9f7d1e87992078ac4ce15d188955a2962aa9e754d0af03", size = 204063, upload-time = "2025-10-29T16:19:50.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0a3"
|
||||
version = "1.0.0a5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bashlex" },
|
||||
@ -1910,9 +1910,9 @@ dependencies = [
|
||||
{ name = "openhands-sdk" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/93/53cf1a5ae97e0c23d7e024db5bbb1ba1da9855c6352cc91d6b65fc6f5e13/openhands_tools-1.0.0a3.tar.gz", hash = "sha256:2a15fff3749ee5856906ffce999fec49c8305e7f9911f05e01dbcf4ea772e385", size = 59103, upload-time = "2025-10-20T15:38:43.705Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/8d/d62bc5e6c986676363692743688f10b6a922fd24dd525e5c6e87bd6fc08e/openhands_tools-1.0.0a5.tar.gz", hash = "sha256:6c67454e612596e95c5151267659ddd3b633a5d4a1b70b348f7f913c62146562", size = 63012, upload-time = "2025-10-29T16:19:53.783Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/aa/251ce4ecd560cad295e1c81def9efadfd1009cec3b7e79bd41357c6a0670/openhands_tools-1.0.0a3-py3-none-any.whl", hash = "sha256:f4c81df682c2a1a1c0bfa450bfe25ba9de5a6a3b56d6bab90f7541bf149bb3ed", size = 78814, upload-time = "2025-10-20T15:38:42.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/9d/4da48258f0af73d017b61ed3f12786fae4caccc7e7cd97d77ef2bb25f00c/openhands_tools-1.0.0a5-py3-none-any.whl", hash = "sha256:74c27e23e6adc9a0bad00e32448bd4872019ce0786474e8de2fbf2d7c0887e8e", size = 84724, upload-time = "2025-10-29T16:19:52.84Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -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/openhands/runtime:0.59-nikolaik"
|
||||
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.60-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
75
poetry.lock
generated
75
poetry.lock
generated
@ -254,19 +254,20 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.59.0"
|
||||
version = "0.72.0"
|
||||
description = "The official Python library for the anthropic API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "anthropic-0.59.0-py3-none-any.whl", hash = "sha256:cbc8b3dccef66ad6435c4fa1d317e5ebb092399a4b88b33a09dc4bf3944c3183"},
|
||||
{file = "anthropic-0.59.0.tar.gz", hash = "sha256:d710d1ef0547ebbb64b03f219e44ba078e83fc83752b96a9b22e9726b523fd8f"},
|
||||
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
|
||||
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.5.0,<5"
|
||||
distro = ">=1.7.0,<2"
|
||||
docstring-parser = ">=0.15,<1"
|
||||
google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""}
|
||||
httpx = ">=0.25.0,<1"
|
||||
jiter = ">=0.4.0,<1"
|
||||
@ -275,7 +276,7 @@ sniffio = "*"
|
||||
typing-extensions = ">=4.10,<5"
|
||||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
|
||||
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
|
||||
vertex = ["google-auth[requests] (>=2,<3)"]
|
||||
|
||||
@ -1204,19 +1205,19 @@ botocore = ["botocore"]
|
||||
|
||||
[[package]]
|
||||
name = "browser-use"
|
||||
version = "0.7.10"
|
||||
version = "0.8.0"
|
||||
description = "Make websites accessible for AI agents"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"},
|
||||
{file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"},
|
||||
{file = "browser_use-0.8.0-py3-none-any.whl", hash = "sha256:b7c299e38ec1c1aec42a236cc6ad2268a366226940d6ff9d88ed461afd5a1cc3"},
|
||||
{file = "browser_use-0.8.0.tar.gz", hash = "sha256:2136eb3251424f712a08ee379c9337237c2f93b29b566807db599cf94e6abb5e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = "3.12.15"
|
||||
anthropic = ">=0.58.2,<1.0.0"
|
||||
anthropic = ">=0.68.1,<1.0.0"
|
||||
anyio = ">=4.9.0"
|
||||
authlib = ">=1.6.0"
|
||||
bubus = ">=1.5.6"
|
||||
@ -1248,11 +1249,11 @@ typing-extensions = ">=4.12.2"
|
||||
uuid7 = ">=0.1.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
|
||||
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
|
||||
aws = ["boto3 (>=1.38.45)"]
|
||||
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
|
||||
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"]
|
||||
examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
|
||||
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
|
||||
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
|
||||
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
|
||||
|
||||
[[package]]
|
||||
@ -5711,8 +5712,11 @@ files = [
|
||||
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
|
||||
@ -7272,13 +7276,15 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.0.0a4"
|
||||
version = "1.0.0a5"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.0.0a5-py3-none-any.whl", hash = "sha256:823fecd33fd45ba64acc6960beda24df2af6520c26c8c110564d0e3679c53186"},
|
||||
{file = "openhands_agent_server-1.0.0a5.tar.gz", hash = "sha256:65458923905f215666e59654e47f124e4c597fe982ede7d54184c8795d810a35"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiosqlite = ">=0.19"
|
||||
@ -7291,22 +7297,17 @@ uvicorn = ">=0.31.1"
|
||||
websockets = ">=12"
|
||||
wsproto = ">=1.2.0"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0a4"
|
||||
version = "1.0.0a5"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "openhands_sdk-1.0.0a5-py3-none-any.whl", hash = "sha256:db20272b04cf03627f9f7d1e87992078ac4ce15d188955a2962aa9e754d0af03"},
|
||||
{file = "openhands_sdk-1.0.0a5.tar.gz", hash = "sha256:8888d6892d58cf9b11a71fa80086156c0b6c9a0b50df6839c0a9cafffba2338c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
fastmcp = ">=2.11.3"
|
||||
@ -7321,40 +7322,28 @@ websockets = ">=12"
|
||||
[package.extras]
|
||||
boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
subdirectory = "openhands-sdk"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0a4"
|
||||
version = "1.0.0a5"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "openhands_tools-1.0.0a5-py3-none-any.whl", hash = "sha256:74c27e23e6adc9a0bad00e32448bd4872019ce0786474e8de2fbf2d7c0887e8e"},
|
||||
{file = "openhands_tools-1.0.0a5.tar.gz", hash = "sha256:6c67454e612596e95c5151267659ddd3b633a5d4a1b70b348f7f913c62146562"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
bashlex = ">=0.18"
|
||||
binaryornot = ">=0.4.4"
|
||||
browser-use = ">=0.7.7"
|
||||
browser-use = ">=0.8.0"
|
||||
cachetools = "*"
|
||||
func-timeout = ">=4.3.5"
|
||||
libtmux = ">=0.46.2"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2.11.7"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
subdirectory = "openhands-tools"
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
@ -16521,4 +16510,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "aed9fa5020f1fdda19cf8191ac75021f2617e10e49757bcec23586b2392fd596"
|
||||
content-hash = "f2234ef5fb5e97bc187d433eae9fcab8903a830d6557fb3926b0c3f37730dd17"
|
||||
|
||||
@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.59.0"
|
||||
version = "0.60.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
@ -113,9 +113,12 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
|
||||
pybase62 = "^1.0.0"
|
||||
|
||||
# V1 dependencies
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" }
|
||||
openhands-sdk = "1.0.0a5"
|
||||
openhands-agent-server = "1.0.0a5"
|
||||
openhands-tools = "1.0.0a5"
|
||||
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
|
||||
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
pg8000 = "^1.31.5"
|
||||
|
||||
8
third_party/runtime/impl/daytona/README.md
vendored
8
third_party/runtime/impl/daytona/README.md
vendored
@ -85,14 +85,14 @@ This command pulls and runs the OpenHands container using Docker. Once executed,
|
||||
#### Mac/Linux:
|
||||
```bash
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${OPENHANDS_VERSION}-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${OPENHANDS_VERSION}-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-e RUNTIME=daytona \
|
||||
-e DAYTONA_API_KEY=${DAYTONA_API_KEY} \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/openhands/openhands:${OPENHANDS_VERSION}
|
||||
docker.openhands.dev/openhands/openhands:${OPENHANDS_VERSION}
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
@ -100,14 +100,14 @@ docker run -it --rm --pull=always \
|
||||
#### Windows:
|
||||
```powershell
|
||||
docker run -it --rm --pull=always `
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${env:OPENHANDS_VERSION}-nikolaik `
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${env:OPENHANDS_VERSION}-nikolaik `
|
||||
-e LOG_ALL_EVENTS=true `
|
||||
-e RUNTIME=daytona `
|
||||
-e DAYTONA_API_KEY=${env:DAYTONA_API_KEY} `
|
||||
-v ~/.openhands:/.openhands `
|
||||
-p 3000:3000 `
|
||||
--name openhands-app `
|
||||
docker.all-hands.dev/openhands/openhands:${env:OPENHANDS_VERSION}
|
||||
docker.openhands.dev/openhands/openhands:${env:OPENHANDS_VERSION}
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user