mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b0ccdce94 | |||
| 288bcd254e | |||
| 3608870c4e | |||
| c71ef11a25 | |||
| 0ab9d97f2d | |||
| b326433330 | |||
| a5931177fd | |||
| aa17460cc5 | |||
| 8bf197df31 | |||
| f74243542d | |||
| a4ebb5bf85 | |||
| cc1aadaba5 | |||
| 8bceee9e42 | |||
| e8fe9ae67e | |||
| 06c68082bb | |||
| 6cfaad86ea | |||
| a899f80ee9 | |||
| 292217b8b4 | |||
| a828318494 | |||
| 47515ea876 | |||
| 860f02ff49 | |||
| f5fa076fdd | |||
| d3043ec898 | |||
| b3baea2421 | |||
| 9e625ec6c5 | |||
| d7c49a0656 | |||
| 34bf6a6402 | |||
| 5deb47aacc | |||
| f409ef9eab | |||
| 294b122120 | |||
| 5d1f39696c | |||
| 11f0fa30b5 | |||
| f22acb63c8 | |||
| b172a92ea9 | |||
| f413b6c6aa | |||
| a3002fa2ea | |||
| 9478ad64e0 | |||
| f56b05f6a9 | |||
| 9bd85adef5 | |||
| 00dd5bccb4 | |||
| c8c79766eb | |||
| a05f2d0059 | |||
| 990738aab2 | |||
| e5676d2946 | |||
| fecfa7d2ee | |||
| 9c0910ca24 | |||
| 862c591f02 | |||
| cba094438b | |||
| a99c6467be | |||
| 6acc5baf22 | |||
| 3f6336de71 | |||
| 0837b69080 | |||
| 1a624cda33 | |||
| 36068b5bf9 |
Vendored
BIN
Binary file not shown.
@@ -40,6 +40,9 @@ def get_config(
|
||||
) -> AppConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = base_container_image
|
||||
sandbox_config.enable_auto_lint = True
|
||||
# If the web services are running on the host machine, this must be set to True
|
||||
sandbox_config.use_host_network = True
|
||||
config = AppConfig(
|
||||
run_as_openhands=False,
|
||||
max_budget_per_task=4,
|
||||
|
||||
@@ -145,7 +145,7 @@ while IFS= read -r task_image; do
|
||||
docker pull $task_image
|
||||
|
||||
# Build the Python command
|
||||
COMMAND="poetry run python run_infer.py \
|
||||
COMMAND="poetry run python -m evaluation.benchmarks.the_agent_company.run_infer \
|
||||
--agent-llm-config \"$AGENT_LLM_CONFIG\" \
|
||||
--env-llm-config \"$ENV_LLM_CONFIG\" \
|
||||
--outputs-path \"$OUTPUTS_PATH\" \
|
||||
|
||||
@@ -57,6 +57,6 @@ describe("Browser", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText("https://example.com")).toBeInTheDocument();
|
||||
expect(screen.getByAltText(/browser screenshot/i)).toBeInTheDocument();
|
||||
expect(screen.getByAltText("BROWSER$SCREENSHOT_ALT")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("CopyToClipboardButton", () => {
|
||||
test("should have localized aria-label", () => {
|
||||
render(
|
||||
<CopyToClipboardButton
|
||||
isHidden={false}
|
||||
isDisabled={false}
|
||||
onClick={() => {}}
|
||||
mode="copy"
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("copy-to-clipboard");
|
||||
expect(button).toHaveAttribute("aria-label", "BUTTON$COPY");
|
||||
});
|
||||
|
||||
test("should have localized aria-label when copied", () => {
|
||||
render(
|
||||
<CopyToClipboardButton
|
||||
isHidden={false}
|
||||
isDisabled={false}
|
||||
onClick={() => {}}
|
||||
mode="copied"
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("copy-to-clipboard");
|
||||
expect(button).toHaveAttribute("aria-label", "BUTTON$COPIED");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,20 @@ vi.mock("#/context/auth-context", () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"ACTION$PUSH_TO_BRANCH": "Push to Branch",
|
||||
"ACTION$PUSH_CREATE_PR": "Push & Create PR",
|
||||
"ACTION$PUSH_CHANGES_TO_PR": "Push Changes to PR"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ActionSuggestions", () => {
|
||||
// Setup mocks for each test
|
||||
beforeEach(() => {
|
||||
@@ -84,7 +98,7 @@ describe("ActionSuggestions", () => {
|
||||
const pushBranchPrompt =
|
||||
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
|
||||
const createPRPrompt =
|
||||
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes.";
|
||||
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes. If a pull request template exists in the repository, please follow it when creating the PR description.";
|
||||
|
||||
// Verify the prompts are different
|
||||
expect(pushBranchPrompt).not.toEqual(createPRPrompt);
|
||||
|
||||
+34
-3
@@ -15,6 +15,31 @@ import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
|
||||
import { clickOnEditButton } from "./utils";
|
||||
|
||||
// We'll use the actual i18next implementation but override the translation function
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
|
||||
// Mock the t function to return our custom translations
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"CONVERSATION$CREATED": "Created",
|
||||
"CONVERSATION$AGO": "ago",
|
||||
"CONVERSATION$UPDATED": "Updated"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ConversationCard", () => {
|
||||
const onClick = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
@@ -47,12 +72,18 @@ describe("ConversationCard", () => {
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
|
||||
|
||||
const card = screen.getByTestId("conversation-card");
|
||||
|
||||
within(card).getByText("Conversation 1");
|
||||
within(card).getByText(expectedDate);
|
||||
|
||||
// Just check that the card contains the expected text content
|
||||
expect(card).toHaveTextContent("Created");
|
||||
expect(card).toHaveTextContent("ago");
|
||||
|
||||
// Use a regex to match the time part since it might have whitespace
|
||||
const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
|
||||
expect(card).toHaveTextContent(timeRegex);
|
||||
});
|
||||
|
||||
it("should render the selectedRepository if available", () => {
|
||||
@@ -341,7 +372,7 @@ describe("ConversationCard", () => {
|
||||
await user.click(displayCostButton);
|
||||
|
||||
// Verify if metrics modal is displayed by checking for the modal content
|
||||
expect(screen.getByText("Metrics Information")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("metrics-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the edit or delete options if the handler is not provided", async () => {
|
||||
|
||||
+6
-6
@@ -135,10 +135,10 @@ describe("ConversationPanel", () => {
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Cancel the deletion
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeInTheDocument();
|
||||
|
||||
// Ensure the conversation is not deleted
|
||||
cards = await screen.findAllByTestId("conversation-card");
|
||||
@@ -172,10 +172,10 @@ describe("ConversationPanel", () => {
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Confirm the deletion
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument();
|
||||
|
||||
// Wait for the cards to update with a longer timeout
|
||||
await waitFor(() => {
|
||||
@@ -239,10 +239,10 @@ describe("ConversationPanel", () => {
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Confirm the deletion
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument();
|
||||
|
||||
// Wait for the cards to update
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("PaymentForm", () => {
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12);
|
||||
@@ -76,7 +76,7 @@ describe("PaymentForm", () => {
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.125456");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13);
|
||||
@@ -86,7 +86,7 @@ describe("PaymentForm", () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
expect(topUpButton).toBeDisabled();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
@@ -102,7 +102,7 @@ describe("PaymentForm", () => {
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(topUpButton).toBeDisabled();
|
||||
@@ -116,7 +116,7 @@ describe("PaymentForm", () => {
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "-50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
@@ -129,7 +129,7 @@ describe("PaymentForm", () => {
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, " ");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
@@ -142,7 +142,7 @@ describe("PaymentForm", () => {
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "abc");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
@@ -155,7 +155,7 @@ describe("PaymentForm", () => {
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "9"); // test assumes the minimum is 10
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("WaitlistModal", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const button = screen.getByRole("button", { name: "Connect to GitHub" });
|
||||
const button = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("WaitlistModal", () => {
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await user.click(checkbox);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Connect to GitHub" });
|
||||
const button = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
|
||||
await user.click(button);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("UserAvatar", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText("user avatar")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -63,6 +63,6 @@ describe("UserAvatar", () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
|
||||
expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
WsClientProvider,
|
||||
useWsClient,
|
||||
} from "#/context/ws-client-provider";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
|
||||
describe("Propagate error message", () => {
|
||||
it("should do nothing when no message was passed from server", () => {
|
||||
@@ -90,9 +91,11 @@ describe("WsClientProvider", () => {
|
||||
const { getByText } = render(<TestComponent />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<WsClientProvider conversationId="test-conversation-id">
|
||||
{children}
|
||||
</WsClientProvider>
|
||||
<AuthProvider initialProviderTokens={[]}>
|
||||
<WsClientProvider conversationId="test-conversation-id">
|
||||
{children}
|
||||
</WsClientProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -88,6 +88,6 @@ describe("Settings Billing", () => {
|
||||
await user.click(credits);
|
||||
|
||||
const billingSection = await screen.findByTestId("billing-settings");
|
||||
within(billingSection).getByText("Manage Credits");
|
||||
within(billingSection).getByText("PAYMENT$MANAGE_CREDITS");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,11 +64,11 @@ describe("Settings Screen", () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByText("LLM Settings");
|
||||
screen.getByText("Git Provider Settings");
|
||||
screen.getByText("Additional Settings");
|
||||
screen.getByText("Reset to defaults");
|
||||
screen.getByText("Save Changes");
|
||||
// Use queryAllByText to handle multiple elements with the same text
|
||||
expect(screen.queryAllByText("SETTINGS$LLM_SETTINGS")).not.toHaveLength(0);
|
||||
screen.getByText("ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS");
|
||||
screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
screen.getByText("BUTTON$SAVE");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,49 +150,7 @@ describe("Settings Screen", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should render a disabled 'Disconnect Tokens' button if the GitHub token is not set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = await screen.findByText("Disconnect Tokens");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should render an enabled 'Disconnect Tokens' button if any Git tokens are set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: mock_provider_tokens_are_set,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
const button = await screen.findByText("Disconnect Tokens");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeEnabled();
|
||||
|
||||
// input should still be rendered
|
||||
const input = await screen.findByTestId("github-token-input");
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should logout the user when the 'Disconnect Tokens' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: mock_provider_tokens_are_set,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = await screen.findByText("Disconnect Tokens");
|
||||
await user.click(button);
|
||||
|
||||
expect(handleLogoutMock).toHaveBeenCalled();
|
||||
});
|
||||
// Tests for DISCONNECT_FROM_GITHUB button removed as the button is no longer included in main
|
||||
|
||||
it("should not render the 'Configure GitHub Repositories' button if OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
@@ -207,7 +165,7 @@ describe("Settings Screen", () => {
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = screen.queryByText("Configure GitHub Repositories");
|
||||
const button = screen.queryByText("GITHUB$CONFIGURE_REPOS");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -224,7 +182,7 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
await screen.findByText("Configure GitHub Repositories");
|
||||
await screen.findByText("GITHUB$CONFIGURE_REPOS");
|
||||
});
|
||||
|
||||
it("should not render the GitHub token input if SaaS mode", async () => {
|
||||
@@ -268,7 +226,7 @@ describe("Settings Screen", () => {
|
||||
const input = await screen.findByTestId("github-token-input");
|
||||
await user.type(input, "invalid-token");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
llmProviderInput = await screen.findByTestId("llm-provider-input");
|
||||
@@ -548,7 +506,7 @@ describe("Settings Screen", () => {
|
||||
const option = await screen.findByText("2x (4 core, 16G)");
|
||||
await user.click(option);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -564,7 +522,7 @@ describe("Settings Screen", () => {
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -595,7 +553,7 @@ describe("Settings Screen", () => {
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const resetButton = screen.getByText("Reset to defaults");
|
||||
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
// show modal
|
||||
@@ -643,7 +601,7 @@ describe("Settings Screen", () => {
|
||||
);
|
||||
await user.click(confirmationModeSwitch);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -756,7 +714,7 @@ describe("Settings Screen", () => {
|
||||
|
||||
expect(languageInput).toHaveValue("Norsk");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -793,7 +751,7 @@ describe("Settings Screen", () => {
|
||||
const gpt4Option = await screen.findByText("gpt-4o");
|
||||
await user.click(gpt4Option);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -818,7 +776,7 @@ describe("Settings Screen", () => {
|
||||
|
||||
expect(languageInput).toHaveValue("Norsk");
|
||||
|
||||
const resetButton = screen.getByText("Reset to defaults");
|
||||
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
@@ -866,7 +824,7 @@ describe("Settings Screen", () => {
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const resetButton = await screen.findByText("Reset to defaults");
|
||||
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
@@ -895,7 +853,7 @@ describe("Settings Screen", () => {
|
||||
await user.click(analyticsConsentInput);
|
||||
expect(analyticsConsentInput).toBeChecked();
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
@@ -909,7 +867,7 @@ describe("Settings Screen", () => {
|
||||
);
|
||||
renderSettingsScreen();
|
||||
|
||||
const saveButton = await screen.findByText("Save Changes");
|
||||
const saveButton = await screen.findByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
|
||||
@@ -942,7 +900,7 @@ describe("Settings Screen", () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -959,7 +917,7 @@ describe("Settings Screen", () => {
|
||||
const input = await screen.findByTestId("llm-api-key-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -979,7 +937,7 @@ describe("Settings Screen", () => {
|
||||
const input = await screen.findByTestId("llm-api-key-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -994,7 +952,7 @@ describe("Settings Screen", () => {
|
||||
const input = await screen.findByTestId("llm-api-key-input");
|
||||
await user.type(input, "new-api-key");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
const saveButton = screen.getByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -1074,7 +1032,7 @@ describe("Settings Screen", () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const saveButton = await screen.findByText("Save Changes");
|
||||
const saveButton = await screen.findByText("BUTTON$SAVE");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
@@ -1090,7 +1048,7 @@ describe("Settings Screen", () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const resetButton = await screen.findByText("Reset to defaults");
|
||||
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
|
||||
@@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
import { ChatInput } from "#/components/features/chat/chat-input";
|
||||
import path from 'path';
|
||||
import { scanDirectoryForUnlocalizedStrings } from "#/utils/scan-unlocalized-strings-ast";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
@@ -37,4 +39,23 @@ describe("Check for hardcoded English strings", () => {
|
||||
render(<ChatInput onSubmit={() => {}} />);
|
||||
screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
|
||||
});
|
||||
});
|
||||
|
||||
test("No unlocalized strings should exist in frontend code", () => {
|
||||
const srcPath = path.resolve(__dirname, '../../src');
|
||||
|
||||
// Get unlocalized strings using the AST scanner
|
||||
// The scanner now properly handles CSS classes using AST information
|
||||
const results = scanDirectoryForUnlocalizedStrings(srcPath);
|
||||
|
||||
// If we found any unlocalized strings, format them for output
|
||||
if (results.size > 0) {
|
||||
const formattedResults = Array.from(results.entries())
|
||||
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
|
||||
.join('\n');
|
||||
|
||||
throw new Error(
|
||||
`Found unlocalized strings in the following files:${formattedResults}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
+3
@@ -51,6 +51,9 @@
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/traverse": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@react-router/dev": "^7.4.0",
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/traverse": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@react-router/dev": "^7.4.0",
|
||||
|
||||
@@ -54,14 +54,12 @@ export const retrieveGitHubAppRepositories = async (
|
||||
* Given a PAT, retrieves the repositories of the user
|
||||
* @returns A list of repositories
|
||||
*/
|
||||
export const retrieveUserGitRepositories = async (page = 1, per_page = 30) => {
|
||||
export const retrieveUserGitRepositories = async () => {
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -224,7 +224,7 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
selectedRepository?: string,
|
||||
selectedRepository?: GitRepository,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
replayJson?: string,
|
||||
@@ -323,11 +323,6 @@ class OpenHands {
|
||||
return user;
|
||||
}
|
||||
|
||||
static async getGitHubUserInstallationIds(): Promise<number[]> {
|
||||
const response = await openHands.get<number[]>("/api/user/installations");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BaseModalTitle,
|
||||
BaseModalDescription,
|
||||
@@ -7,6 +8,7 @@ import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AnalyticsConsentFormModalProps {
|
||||
onClose: () => void;
|
||||
@@ -15,6 +17,7 @@ interface AnalyticsConsentFormModalProps {
|
||||
export function AnalyticsConsentFormModal({
|
||||
onClose,
|
||||
}: AnalyticsConsentFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -41,16 +44,14 @@ export function AnalyticsConsentFormModal({
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<BaseModalTitle title="Your Privacy Preferences" />
|
||||
<BaseModalTitle title={t(I18nKey.ANALYTICS$TITLE)} />
|
||||
<BaseModalDescription>
|
||||
We use tools to understand how our application is used to improve
|
||||
your experience. You can enable or disable analytics. Your
|
||||
preferences will be stored and can be updated anytime.
|
||||
{t(I18nKey.ANALYTICS$DESCRIPTION)}
|
||||
</BaseModalDescription>
|
||||
|
||||
<label className="flex gap-2 items-center self-start">
|
||||
<input name="analytics" type="checkbox" defaultChecked />
|
||||
Send anonymous usage data
|
||||
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
|
||||
</label>
|
||||
|
||||
<BrandButton
|
||||
@@ -59,7 +60,7 @@ export function AnalyticsConsentFormModal({
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
Confirm Preferences
|
||||
{t(I18nKey.ANALYTICS$CONFIRM_PREFERENCES)}
|
||||
</BrandButton>
|
||||
</ModalBody>
|
||||
</form>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface BrowserSnaphsotProps {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function BrowserSnapshot({ src }: BrowserSnaphsotProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
style={{ objectFit: "contain", width: "100%", height: "auto" }}
|
||||
className="rounded-xl"
|
||||
alt="Browser Screenshot"
|
||||
alt={t(I18nKey.BROWSER$SCREENSHOT_ALT)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
|
||||
import type { RootState } from "#/store";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ActionSuggestionsProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -12,6 +14,7 @@ interface ActionSuggestionsProps {
|
||||
export function ActionSuggestions({
|
||||
onSuggestionsClick,
|
||||
}: ActionSuggestionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { providersAreSet } = useAuth();
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
@@ -35,7 +38,7 @@ export function ActionSuggestions({
|
||||
}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
|
||||
createPR: `Please push the changes to ${
|
||||
isGitLab ? "GitLab" : "GitHub"
|
||||
} and open a ${pr}. Please create a meaningful branch name that describes the changes.`,
|
||||
} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
pushToPR: `Please push the latest changes to the existing ${pr}.`,
|
||||
};
|
||||
|
||||
@@ -47,7 +50,7 @@ export function ActionSuggestions({
|
||||
<>
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: "Push to Branch",
|
||||
label: t(I18nKey.ACTION$PUSH_TO_BRANCH),
|
||||
value: terms.pushToBranch,
|
||||
}}
|
||||
onClick={(value) => {
|
||||
@@ -57,7 +60,7 @@ export function ActionSuggestions({
|
||||
/>
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: `Push & Create ${terms.prShort}`,
|
||||
label: t(I18nKey.ACTION$PUSH_CREATE_PR),
|
||||
value: terms.createPR,
|
||||
}}
|
||||
onClick={(value) => {
|
||||
@@ -70,7 +73,7 @@ export function ActionSuggestions({
|
||||
) : (
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: `Push changes to ${terms.prShort}`,
|
||||
label: t(I18nKey.ACTION$PUSH_CHANGES_TO_PR),
|
||||
value: terms.pushToPR,
|
||||
}}
|
||||
onClick={(value) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useDispatch, useSelector } from "react-redux";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { TrajectoryActions } from "../trajectory/trajectory-actions";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
@@ -36,6 +38,7 @@ function getEntryPoint(
|
||||
export function ChatInterface() {
|
||||
const { send, isLoadingMessages } = useWsClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
|
||||
useScrollToBottom(scrollRef);
|
||||
@@ -94,19 +97,19 @@ export function ChatInterface() {
|
||||
|
||||
const onClickExportTrajectoryButton = () => {
|
||||
if (!params.conversationId) {
|
||||
displayErrorToast("ConversationId unknown, cannot download trajectory");
|
||||
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
getTrajectory(params.conversationId, {
|
||||
onSuccess: async (data) => {
|
||||
await downloadTrajectory(
|
||||
params.conversationId ?? "unknown",
|
||||
params.conversationId ?? t(I18nKey.CONVERSATION$UNKNOWN),
|
||||
data.trajectory,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
displayErrorToast(error.message);
|
||||
onError: () => {
|
||||
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -124,7 +127,7 @@ export function ChatInterface() {
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
|
||||
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
|
||||
>
|
||||
{isLoadingMessages && (
|
||||
<div className="flex justify-center">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Link } from "react-router";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { code } from "../markdown/code";
|
||||
import { ol, ul } from "../markdown/list";
|
||||
import ArrowUp from "#/icons/angle-up-solid.svg?react";
|
||||
@@ -44,7 +45,7 @@ export function ExpandableMessage({
|
||||
if (
|
||||
config?.FEATURE_FLAGS.ENABLE_BILLING &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
id === "STATUS$ERROR_LLM_OUT_OF_CREDITS"
|
||||
id === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
@@ -53,13 +54,13 @@ export function ExpandableMessage({
|
||||
>
|
||||
<div className="text-sm w-full">
|
||||
<div className="font-bold text-danger">
|
||||
{t("STATUS$ERROR_LLM_OUT_OF_CREDITS")}
|
||||
{t(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS)}
|
||||
</div>
|
||||
<Link
|
||||
className="mt-2 mb-2 w-full h-10 rounded flex items-center justify-center gap-2 bg-primary text-[#0D0F11]"
|
||||
to="/settings/billing"
|
||||
>
|
||||
{t("BILLING$CLICK_TO_TOP_UP")}
|
||||
{t(I18nKey.BILLING$CLICK_TO_TOP_UP)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { showErrorToast } from "#/utils/error-handler";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
@@ -78,7 +79,7 @@ export function AgentStatusBar() {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === WsClientProviderStatus.DISCONNECTED) {
|
||||
setStatusMessage("Connecting...");
|
||||
setStatusMessage(t(I18nKey.STATUS$CONNECTED)); // Using STATUS$CONNECTED instead of STATUS$CONNECTING
|
||||
setIndicatorColor(IndicatorColor.RED);
|
||||
} else {
|
||||
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConfirmDeleteModalProps {
|
||||
onConfirm: () => void;
|
||||
@@ -15,12 +17,16 @@ export function ConfirmDeleteModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDeleteModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="items-start border border-tertiary">
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseModalTitle title="Are you sure you want to delete this project?" />
|
||||
<BaseModalDescription description="All data associated with this project will be lost." />
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_DELETE)} />
|
||||
<BaseModalDescription
|
||||
description={t(I18nKey.CONVERSATION$DELETE_WARNING)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col gap-2 w-full"
|
||||
@@ -31,16 +37,18 @@ export function ConfirmDeleteModal({
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
className="w-full"
|
||||
data-testid="confirm-button"
|
||||
>
|
||||
Confirm
|
||||
{t(I18nKey.ACTION$CONFIRM)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
className="w-full"
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
Cancel
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConversationCardProps {
|
||||
onClick?: () => void;
|
||||
@@ -46,6 +48,7 @@ export function ConversationCard({
|
||||
variant = "default",
|
||||
conversationId,
|
||||
}: ConversationCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
@@ -220,14 +223,18 @@ export function ConversationCard({
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<span>Created </span>
|
||||
<span>{t(I18nKey.CONVERSATION$CREATED)} </span>
|
||||
<time>
|
||||
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))} ago
|
||||
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))}{" "}
|
||||
{t(I18nKey.CONVERSATION$AGO)}
|
||||
</time>
|
||||
{showUpdateTime && (
|
||||
<>
|
||||
<span>, updated </span>
|
||||
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
|
||||
<span>{t(I18nKey.CONVERSATION$UPDATED)} </span>
|
||||
<time>
|
||||
{formatTimeDelta(new Date(lastUpdatedAt))}{" "}
|
||||
{t(I18nKey.CONVERSATION$AGO)}
|
||||
</time>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
@@ -237,7 +244,7 @@ export function ConversationCard({
|
||||
<BaseModal
|
||||
isOpen={metricsModalVisible}
|
||||
onOpenChange={setMetricsModalVisible}
|
||||
title="Metrics Information"
|
||||
title={t(I18nKey.CONVERSATION$METRICS_INFO)}
|
||||
testID="metrics-modal"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -247,7 +254,7 @@ export function ConversationCard({
|
||||
{metrics?.cost !== null && (
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<span className="text-lg font-semibold">
|
||||
Total Cost (USD):
|
||||
{t(I18nKey.CONVERSATION$TOTAL_COST)}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
${metrics.cost.toFixed(4)}
|
||||
@@ -258,7 +265,7 @@ export function ConversationCard({
|
||||
{metrics?.usage !== null && (
|
||||
<>
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<span>Total Input Tokens:</span>
|
||||
<span>{t(I18nKey.CONVERSATION$INPUT)}:</span>
|
||||
<span className="font-semibold">
|
||||
{metrics.usage.prompt_tokens.toLocaleString()}
|
||||
</span>
|
||||
@@ -276,14 +283,16 @@ export function ConversationCard({
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
|
||||
<span>Total Output Tokens:</span>
|
||||
<span>{t(I18nKey.CONVERSATION$OUTPUT)}:</span>
|
||||
<span className="font-semibold">
|
||||
{metrics.usage.completion_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-1">
|
||||
<span className="font-semibold">Total Tokens:</span>
|
||||
<span className="font-semibold">
|
||||
{t(I18nKey.CONVERSATION$TOTAL)}:
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{(
|
||||
metrics.usage.prompt_tokens +
|
||||
@@ -299,7 +308,9 @@ export function ConversationCard({
|
||||
|
||||
{!metrics?.cost && !metrics?.usage && (
|
||||
<div className="rounded-md p-4 text-center">
|
||||
<p className="text-neutral-400">No metrics data available</p>
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_METRICS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ExitConversationModalProps {
|
||||
onConfirm: () => void;
|
||||
@@ -12,18 +14,20 @@ export function ExitConversationModal({
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ExitConversationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody testID="confirm-new-conversation-modal">
|
||||
<BaseModalTitle title="Creating a new conversation will replace your active conversation" />
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$EXIT_WARNING)} />
|
||||
<div className="flex w-full gap-2">
|
||||
<ModalButton
|
||||
text="Confirm"
|
||||
text={t(I18nKey.ACTION$CONFIRM)}
|
||||
onClick={onConfirm}
|
||||
className="bg-[#C63143] flex-1"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Cancel"
|
||||
text={t(I18nKey.BUTTON$CANCEL)}
|
||||
onClick={onClose}
|
||||
className="bg-tertiary flex-1"
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
BaseModalTitle,
|
||||
BaseModalDescription,
|
||||
@@ -17,13 +19,14 @@ export function FeedbackModal({
|
||||
isOpen,
|
||||
polarity,
|
||||
}: FeedbackModalProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<BaseModalTitle title="Feedback" />
|
||||
<BaseModalDescription description="To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." />
|
||||
<BaseModalTitle title={t(I18nKey.FEEDBACK$TITLE)} />
|
||||
<BaseModalDescription description={t(I18nKey.FEEDBACK$DESCRIPTION)} />
|
||||
<FeedbackForm onClose={onClose} polarity={polarity} />
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
|
||||
@@ -7,6 +9,7 @@ const INITIAL_PROMPT = "";
|
||||
|
||||
export function CodeNotInGitLink() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { mutate: createConversation } = useCreateConversation();
|
||||
|
||||
const handleStartFromScratch = () => {
|
||||
@@ -17,14 +20,14 @@ export function CodeNotInGitLink() {
|
||||
|
||||
return (
|
||||
<div className="text-xs text-neutral-400">
|
||||
Code not in Git?{" "}
|
||||
{t(I18nKey.GITHUB$CODE_NOT_IN_GITHUB)}{" "}
|
||||
<span
|
||||
onClick={handleStartFromScratch}
|
||||
className="underline cursor-pointer"
|
||||
>
|
||||
Start from scratch
|
||||
{t(I18nKey.GITHUB$START_FROM_SCRATCH)}
|
||||
</span>{" "}
|
||||
and use the VS Code link to upload and download your code.
|
||||
{t(I18nKey.GITHUB$VSCODE_LINK_DESCRIPTION)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useNavigate } from "react-router";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
|
||||
import { GitRepositorySelector } from "./git-repo-selector";
|
||||
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
@@ -31,20 +30,15 @@ export function GitRepositoriesSuggestionBox({
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
// TODO: Use `useQueries` to fetch all repositories in parallel
|
||||
const { data: appRepositories, isLoading: isAppReposLoading } =
|
||||
useAppRepositories();
|
||||
const { data: userRepositories, isLoading: isUserReposLoading } =
|
||||
useUserRepositories();
|
||||
const { data: searchedRepos, isLoading: isSearchReposLoading } =
|
||||
useSearchRepositories(sanitizeQuery(debouncedSearchQuery));
|
||||
|
||||
const isLoading =
|
||||
isAppReposLoading || isUserReposLoading || isSearchReposLoading;
|
||||
const isLoading = isUserReposLoading || isSearchReposLoading;
|
||||
|
||||
const repositories =
|
||||
userRepositories?.pages.flatMap((page) => page.data) ||
|
||||
appRepositories?.pages.flatMap((page) => page.data) ||
|
||||
[];
|
||||
userRepositories?.pages.flatMap((page) => page.data) || [];
|
||||
|
||||
const handleConnectToGitHub = () => {
|
||||
if (gitHubAuthUrl) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
|
||||
<div
|
||||
data-testid="jupyter-container"
|
||||
className="flex-1 overflow-y-auto"
|
||||
className="flex-1 overflow-y-auto fast-smooth-scroll"
|
||||
ref={jupyterRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { cn } from "#/utils/utils";
|
||||
@@ -7,8 +8,10 @@ import { SettingsInput } from "../settings/settings-input";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function PaymentForm() {
|
||||
const { t } = useTranslation();
|
||||
const { data: balance, isLoading } = useBalance();
|
||||
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
|
||||
|
||||
@@ -38,7 +41,7 @@ export function PaymentForm() {
|
||||
className="flex flex-col gap-6 px-11 py-9"
|
||||
>
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
Manage Credits
|
||||
{t(I18nKey.PAYMENT$MANAGE_CREDITS)}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
@@ -63,7 +66,7 @@ export function PaymentForm() {
|
||||
name="top-up-input"
|
||||
onChange={handleTopUpInputChange}
|
||||
type="text"
|
||||
label="Add funds"
|
||||
label={t(I18nKey.PAYMENT$ADD_FUNDS)}
|
||||
placeholder="Specify an amount in USD to add - min $10"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
@@ -74,7 +77,7 @@ export function PaymentForm() {
|
||||
type="submit"
|
||||
isDisabled={isPending || buttonIsDisabled}
|
||||
>
|
||||
Add credit
|
||||
{t(I18nKey.PAYMENT$ADD_CREDIT)}
|
||||
</BrandButton>
|
||||
{isPending && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
@@ -15,7 +16,7 @@ export function SetupPaymentModal() {
|
||||
window.location.href = data;
|
||||
},
|
||||
onError: () => {
|
||||
displayErrorToast(t("BILLING$ERROR_WHILE_CREATING_SESSION"));
|
||||
displayErrorToast(t(I18nKey.BILLING$ERROR_WHILE_CREATING_SESSION));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,7 +25,9 @@ export function SetupPaymentModal() {
|
||||
<ModalBody className="border border-tertiary">
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">{t("BILLING$YOUVE_GOT_50")}</h1>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.BILLING$YOUVE_GOT_50)}
|
||||
</h1>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="BILLING$CLAIM_YOUR_50"
|
||||
@@ -40,7 +43,7 @@ export function SetupPaymentModal() {
|
||||
isDisabled={isPending}
|
||||
onClick={mutate}
|
||||
>
|
||||
{t("BILLING$PROCEED_TO_STRIPE")}
|
||||
{t(I18nKey.BILLING$PROCEED_TO_STRIPE)}
|
||||
</BrandButton>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AvatarProps {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function Avatar({ src }: AvatarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<img src={src} alt="user avatar" className="w-full h-full rounded-full" />
|
||||
<img
|
||||
src={src}
|
||||
alt={t(I18nKey.AVATAR$ALT_TEXT)}
|
||||
className="w-full h-full rounded-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FaListUl } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { NavLink, useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGitUser } from "#/hooks/query/use-git-user";
|
||||
import { UserActions } from "./user-actions";
|
||||
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||
@@ -21,8 +22,10 @@ import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
@@ -91,8 +94,8 @@ export function Sidebar() {
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
<TooltipButton
|
||||
testId="toggle-conversation-panel"
|
||||
tooltip="Conversations"
|
||||
ariaLabel="Conversations"
|
||||
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
|
||||
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
>
|
||||
<FaListUl
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
|
||||
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
|
||||
import ExportIcon from "#/icons/export.svg?react";
|
||||
@@ -23,19 +24,19 @@ export function TrajectoryActions({
|
||||
testId="positive-feedback"
|
||||
onClick={onPositiveFeedback}
|
||||
icon={<ThumbsUpIcon width={15} height={15} />}
|
||||
tooltip={t("BUTTON$MARK_HELPFUL")}
|
||||
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
|
||||
/>
|
||||
<TrajectoryActionButton
|
||||
testId="negative-feedback"
|
||||
onClick={onNegativeFeedback}
|
||||
icon={<ThumbDownIcon width={15} height={15} />}
|
||||
tooltip={t("BUTTON$MARK_NOT_HELPFUL")}
|
||||
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
|
||||
/>
|
||||
<TrajectoryActionButton
|
||||
testId="export-trajectory"
|
||||
onClick={onExportTrajectory}
|
||||
icon={<ExportIcon width={15} height={15} />}
|
||||
tooltip={t("BUTTON$EXPORT_CONVERSATION")}
|
||||
tooltip={t(I18nKey.BUTTON$EXPORT_CONVERSATION)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function JoinWaitlistAnchor() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<a
|
||||
href="https://www.all-hands.dev/join-waitlist"
|
||||
@@ -6,7 +11,7 @@ export function JoinWaitlistAnchor() {
|
||||
rel="noreferrer"
|
||||
className="rounded bg-[#FFE165] text-black text-sm font-bold py-[10px] w-full text-center hover:opacity-80"
|
||||
>
|
||||
Join Waitlist
|
||||
{t(I18nKey.WAITLIST$JOIN_WAITLIST)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface WaitlistMessageProps {
|
||||
content: "waitlist" | "sign-in";
|
||||
}
|
||||
|
||||
export function WaitlistMessage({ content }: WaitlistMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{content === "sign-in" && "Sign in with GitHub"}
|
||||
{content === "waitlist" && "Just a little longer!"}
|
||||
{content === "sign-in" && t(I18nKey.AUTH$SIGN_IN_WITH_GITHUB)}
|
||||
{content === "waitlist" && t(I18nKey.WAITLIST$ALMOST_THERE)}
|
||||
</h1>
|
||||
{content === "sign-in" && (
|
||||
<p>
|
||||
or{" "}
|
||||
{t(I18nKey.LANDING$OR)}{" "}
|
||||
<a
|
||||
href="https://www.all-hands.dev/join-waitlist"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-blue-500 hover:underline underline-offset-2"
|
||||
>
|
||||
join the waitlist
|
||||
{t(I18nKey.WAITLIST$JOIN)}
|
||||
</a>{" "}
|
||||
if you haven't already
|
||||
{t(I18nKey.WAITLIST$IF_NOT_JOINED)}
|
||||
</p>
|
||||
)}
|
||||
{content === "waitlist" && (
|
||||
<p className="text-sm">
|
||||
Thanks for your patience! We're accepting new members
|
||||
progressively. If you haven't joined the waitlist yet, now's
|
||||
the time!
|
||||
</p>
|
||||
<p className="text-sm">{t(I18nKey.WAITLIST$PATIENCE_MESSAGE)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { JoinWaitlistAnchor } from "./join-waitlist-anchor";
|
||||
import { WaitlistMessage } from "./waitlist-message";
|
||||
@@ -18,6 +20,7 @@ export function WaitlistModal({
|
||||
ghTokenIsSet,
|
||||
githubAuthUrl,
|
||||
}: WaitlistModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
@@ -44,7 +47,7 @@ export function WaitlistModal({
|
||||
className="w-full"
|
||||
startContent={<GitHubLogo width={20} height={20} />}
|
||||
>
|
||||
Connect to GitHub
|
||||
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
|
||||
</BrandButton>
|
||||
)}
|
||||
{ghTokenIsSet && <JoinWaitlistAnchor />}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
@@ -6,10 +8,11 @@ interface AllHandsLogoButtonProps {
|
||||
}
|
||||
|
||||
export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipButton
|
||||
tooltip="All Hands AI"
|
||||
ariaLabel="All Hands Logo"
|
||||
tooltip={t(I18nKey.BRANDING$ALL_HANDS_AI)}
|
||||
ariaLabel={t(I18nKey.BRANDING$ALL_HANDS_LOGO)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<AllHandsLogo width={34} height={34} />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckmarkIcon from "#/icons/checkmark.svg?react";
|
||||
import CopyIcon from "#/icons/copy.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface CopyToClipboardButtonProps {
|
||||
isHidden: boolean;
|
||||
@@ -14,6 +16,7 @@ export function CopyToClipboardButton({
|
||||
onClick,
|
||||
mode,
|
||||
}: CopyToClipboardButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
hidden={isHidden}
|
||||
@@ -21,6 +24,9 @@ export function CopyToClipboardButton({
|
||||
data-testid="copy-to-clipboard"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={t(
|
||||
mode === "copy" ? I18nKey.BUTTON$COPY : I18nKey.BUTTON$COPIED,
|
||||
)}
|
||||
className="button-base p-1 absolute top-1 right-1"
|
||||
>
|
||||
{mode === "copy" && <CopyIcon width={15} height={15} />}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { IoIosRefresh } from "react-icons/io";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconButton } from "./icon-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface RefreshIconButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={
|
||||
@@ -15,7 +19,7 @@ export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
|
||||
/>
|
||||
}
|
||||
testId="refresh"
|
||||
ariaLabel="Refresh workspace"
|
||||
ariaLabel={t(I18nKey.WORKSPACE$REFRESH)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IoIosArrowForward, IoIosArrowBack } from "react-icons/io";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconButton } from "./icon-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ToggleWorkspaceIconButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -10,6 +12,8 @@ export function ToggleWorkspaceIconButton({
|
||||
onClick,
|
||||
isHidden,
|
||||
}: ToggleWorkspaceIconButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={
|
||||
@@ -26,7 +30,9 @@ export function ToggleWorkspaceIconButton({
|
||||
)
|
||||
}
|
||||
testId="toggle"
|
||||
ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
|
||||
ariaLabel={
|
||||
isHidden ? t(I18nKey.WORKSPACE$OPEN) : t(I18nKey.WORKSPACE$CLOSE)
|
||||
}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function BaseUrlInput({ isDisabled, defaultValue }: BaseUrlInputProps) {
|
||||
id="base-url"
|
||||
name="base-url"
|
||||
defaultValue={defaultValue}
|
||||
aria-label="Base URL"
|
||||
aria-label={t(I18nKey.SETTINGS_FORM$BASE_URL)}
|
||||
classNames={{
|
||||
inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { DangerModal } from "./confirmation-modals/danger-modal";
|
||||
import { ModalBackdrop } from "./modal-backdrop";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ExitProjectConfirmationModalProps {
|
||||
onClose: () => void;
|
||||
@@ -12,6 +14,7 @@ interface ExitProjectConfirmationModalProps {
|
||||
export function ExitProjectConfirmationModal({
|
||||
onClose,
|
||||
}: ExitProjectConfirmationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
|
||||
@@ -24,15 +27,15 @@ export function ExitProjectConfirmationModal({
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<DangerModal
|
||||
title="Are you sure you want to exit?"
|
||||
description="You will lose any unsaved information."
|
||||
title={t(I18nKey.EXIT_PROJECT$CONFIRM)}
|
||||
description={t(I18nKey.EXIT_PROJECT$WARNING)}
|
||||
buttons={{
|
||||
danger: {
|
||||
text: "Exit Project",
|
||||
text: t(I18nKey.EXIT_PROJECT$TITLE),
|
||||
onClick: handleEndSession,
|
||||
},
|
||||
cancel: {
|
||||
text: "Cancel",
|
||||
text: t(I18nKey.BUTTON$CANCEL),
|
||||
onClick: onClose,
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -131,7 +131,10 @@ function SecurityInvariant() {
|
||||
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 p-4 max-h-screen overflow-y-auto" ref={logsRef}>
|
||||
<div
|
||||
className="flex-1 p-4 max-h-screen overflow-y-auto fast-smooth-scroll"
|
||||
ref={logsRef}
|
||||
>
|
||||
{logs.map((log: SecurityAnalyzerLog, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ModelSelector({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AutocompleteSection title="Verified">
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
|
||||
{Object.keys(models)
|
||||
.filter((provider) => VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
@@ -105,7 +105,7 @@ export function ModelSelector({
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
<AutocompleteSection title="Others">
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
|
||||
{Object.keys(models)
|
||||
.filter((provider) => !VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
@@ -143,14 +143,14 @@ export function ModelSelector({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AutocompleteSection title="Verified">
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem key={model}>{model}</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
<AutocompleteSection title="Others">
|
||||
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => !VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
|
||||
@@ -93,7 +93,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label="API Key"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isLLMKeySet ? "<hidden>" : ""}
|
||||
@@ -102,8 +102,8 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
|
||||
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text="Don't know your API key?"
|
||||
linkText="Click here for instructions"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,13 +30,14 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
|
||||
{t(I18nKey.AI_SETTINGS$TITLE)}
|
||||
</span>
|
||||
<p className="text-xs text-[#A3A3A3]">
|
||||
{t(I18nKey.SETTINGS$DESCRIPTION)} For other options,{" "}
|
||||
{t(I18nKey.SETTINGS$DESCRIPTION)}{" "}
|
||||
{t(I18nKey.SETTINGS$FOR_OTHER_OPTIONS)}
|
||||
<Link
|
||||
data-testid="advanced-settings-link"
|
||||
to="/settings"
|
||||
className="underline underline-offset-2 text-white"
|
||||
>
|
||||
see advanced settings
|
||||
{t(I18nKey.SETTINGS$SEE_ADVANCED_SETTINGS)}
|
||||
</Link>
|
||||
</p>
|
||||
{aiConfigOptions.isLoading && (
|
||||
|
||||
@@ -24,7 +24,11 @@ export function ConversationProvider({
|
||||
|
||||
const value = useMemo(() => ({ conversationId }), [conversationId]);
|
||||
|
||||
return <ConversationContext value={value}>{children}</ConversationContext>;
|
||||
return (
|
||||
<ConversationContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConversation() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { useAuth } from "./auth-context";
|
||||
|
||||
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
typeof event === "object" &&
|
||||
@@ -110,6 +111,7 @@ export function WsClientProvider({
|
||||
);
|
||||
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
|
||||
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
|
||||
const { providerTokensSet } = useAuth();
|
||||
|
||||
const messageRateHandler = useRate({ threshold: 250 });
|
||||
|
||||
@@ -168,6 +170,7 @@ export function WsClientProvider({
|
||||
const query = {
|
||||
latest_event_id: lastEvent?.id ?? -1,
|
||||
conversation_id: conversationId,
|
||||
providers_set: providerTokensSet,
|
||||
};
|
||||
|
||||
const baseUrl =
|
||||
|
||||
@@ -20,7 +20,7 @@ export const useCreateConversation = () => {
|
||||
if (variables.q) dispatch(setInitialPrompt(variables.q));
|
||||
|
||||
return OpenHands.createConversation(
|
||||
selectedRepository?.full_name || undefined,
|
||||
selectedRepository || undefined,
|
||||
variables.q,
|
||||
files,
|
||||
replayJson || undefined,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useAppInstallations = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { providersAreSet } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", providersAreSet, config?.GITHUB_CLIENT_ID],
|
||||
queryFn: OpenHands.getGitHubUserInstallationIds,
|
||||
enabled:
|
||||
providersAreSet &&
|
||||
!!config?.GITHUB_CLIENT_ID &&
|
||||
config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { retrieveGitHubAppRepositories } from "#/api/git";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useAppRepositories = () => {
|
||||
const { providersAreSet } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", providersAreSet, installations],
|
||||
queryFn: async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: { installationIndex: number | null; repoPage: number | null };
|
||||
}) => {
|
||||
const { repoPage, installationIndex } = pageParam;
|
||||
|
||||
if (!installations) {
|
||||
throw new Error("Missing installation list");
|
||||
}
|
||||
|
||||
return retrieveGitHubAppRepositories(
|
||||
installationIndex || 0,
|
||||
installations,
|
||||
repoPage || 1,
|
||||
30,
|
||||
);
|
||||
},
|
||||
initialPageParam: { installationIndex: 0, repoPage: 1 },
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.nextPage) {
|
||||
return {
|
||||
installationIndex: lastPage.installationIndex,
|
||||
repoPage: lastPage.nextPage,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastPage.installationIndex !== null) {
|
||||
return { installationIndex: lastPage.installationIndex, repoPage: 1 };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
enabled:
|
||||
providersAreSet &&
|
||||
Array.isArray(installations) &&
|
||||
installations.length > 0 &&
|
||||
config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
// TODO: Once we create our custom dropdown component, we should fetch data onEndReached
|
||||
// (nextui autocomplete doesn't support onEndReached nor is it compatible for extending)
|
||||
const { isSuccess, isFetchingNextPage, hasNextPage, fetchNextPage } = repos;
|
||||
React.useEffect(() => {
|
||||
if (!isFetchingNextPage && isSuccess && hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [isFetchingNextPage, isSuccess, hasNextPage, fetchNextPage]);
|
||||
|
||||
return repos;
|
||||
};
|
||||
@@ -69,9 +69,18 @@ export const useSettings = () => {
|
||||
// that would prepopulate the data to the cache and mess with expectations. Read more:
|
||||
// https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#using-initialdata-to-prepopulate-a-query
|
||||
if (query.error?.status === 404) {
|
||||
// Create a new object with only the properties we need, avoiding rest destructuring
|
||||
return {
|
||||
...query,
|
||||
data: DEFAULT_SETTINGS,
|
||||
error: query.error,
|
||||
isError: query.isError,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isFetched: query.isFetched,
|
||||
isSuccess: query.isSuccess,
|
||||
status: query.status,
|
||||
fetchStatus: query.fetchStatus,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { retrieveUserGitRepositories } from "#/api/git";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useUserRepositories = () => {
|
||||
const { providerTokensSet, providersAreSet } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", providerTokensSet],
|
||||
queryFn: async ({ pageParam }) =>
|
||||
retrieveUserGitRepositories(pageParam, 100),
|
||||
queryFn: async () => retrieveUserGitRepositories(),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
enabled: providersAreSet && config?.APP_MODE === "oss",
|
||||
enabled: providersAreSet,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export function useDocumentTitleFromState(suffix = "OpenHands") {
|
||||
useEffect(() => {
|
||||
if (conversation?.title) {
|
||||
lastValidTitleRef.current = conversation.title;
|
||||
document.title = `${conversation.title} - ${suffix}`;
|
||||
document.title = `${conversation.title} | ${suffix}`;
|
||||
} else {
|
||||
document.title = suffix;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,70 @@
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
import { RefObject, useEffect, useState, useCallback } from "react";
|
||||
|
||||
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
|
||||
// for auto-scroll
|
||||
// Track whether we should auto-scroll to the bottom when content changes
|
||||
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(true);
|
||||
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// Track whether the user is currently at the bottom of the scroll area
|
||||
const [hitBottom, setHitBottom] = useState(true);
|
||||
|
||||
const onChatBodyScroll = (e: HTMLElement) => {
|
||||
const bottomHeight = e.scrollTop + e.clientHeight;
|
||||
// Check if the scroll position is at the bottom
|
||||
const isAtBottom = useCallback((element: HTMLElement): boolean => {
|
||||
const bottomThreshold = 10; // Pixels from bottom to consider "at bottom"
|
||||
const bottomPosition = element.scrollTop + element.clientHeight;
|
||||
return bottomPosition >= element.scrollHeight - bottomThreshold;
|
||||
}, []);
|
||||
|
||||
const isHitBottom = bottomHeight >= e.scrollHeight - 10;
|
||||
// Handle scroll events
|
||||
const onChatBodyScroll = useCallback(
|
||||
(e: HTMLElement) => {
|
||||
const isCurrentlyAtBottom = isAtBottom(e);
|
||||
setHitBottom(isCurrentlyAtBottom);
|
||||
|
||||
setHitBottom(isHitBottom);
|
||||
setAutoScroll(isHitBottom);
|
||||
};
|
||||
// Only update shouldScrollToBottom when user manually scrolls
|
||||
// This prevents content changes from affecting our scroll behavior decision
|
||||
setShouldScrollToBottom(isCurrentlyAtBottom);
|
||||
},
|
||||
[isAtBottom],
|
||||
);
|
||||
|
||||
function scrollDomToBottom() {
|
||||
// Scroll to bottom function with animation
|
||||
const scrollDomToBottom = useCallback(() => {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
requestAnimationFrame(() => {
|
||||
setAutoScroll(true);
|
||||
dom.scrollTo({ top: dom.scrollHeight, behavior: "auto" });
|
||||
// Set shouldScrollToBottom to true when manually scrolling to bottom
|
||||
setShouldScrollToBottom(true);
|
||||
setHitBottom(true);
|
||||
|
||||
// Use smooth scrolling but with a fast duration
|
||||
dom.scrollTo({
|
||||
top: dom.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [scrollRef]);
|
||||
|
||||
// auto scroll
|
||||
// Auto-scroll effect that runs when content changes
|
||||
useEffect(() => {
|
||||
if (autoScroll) {
|
||||
scrollDomToBottom();
|
||||
// Only auto-scroll if the user was already at the bottom
|
||||
if (shouldScrollToBottom) {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
requestAnimationFrame(() => {
|
||||
dom.scrollTo({
|
||||
top: dom.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
autoScroll: shouldScrollToBottom,
|
||||
setAutoScroll: setShouldScrollToBottom,
|
||||
scrollDomToBottom,
|
||||
hitBottom,
|
||||
setHitBottom,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
SETTINGS$ADVANCED = "SETTINGS$ADVANCED",
|
||||
SETTINGS$BASE_URL = "SETTINGS$BASE_URL",
|
||||
SETTINGS$AGENT = "SETTINGS$AGENT",
|
||||
SETTINGS$ENABLE_MEMORY_CONDENSATION = "SETTINGS$ENABLE_MEMORY_CONDENSATION",
|
||||
SETTINGS$LANGUAGE = "SETTINGS$LANGUAGE",
|
||||
ACTION$PUSH_TO_BRANCH = "ACTION$PUSH_TO_BRANCH",
|
||||
ACTION$PUSH_CREATE_PR = "ACTION$PUSH_CREATE_PR",
|
||||
ACTION$PUSH_CHANGES_TO_PR = "ACTION$PUSH_CHANGES_TO_PR",
|
||||
ANALYTICS$TITLE = "ANALYTICS$TITLE",
|
||||
ANALYTICS$DESCRIPTION = "ANALYTICS$DESCRIPTION",
|
||||
ANALYTICS$SEND_ANONYMOUS_DATA = "ANALYTICS$SEND_ANONYMOUS_DATA",
|
||||
ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES",
|
||||
BUTTON$COPY = "BUTTON$COPY",
|
||||
BUTTON$COPIED = "BUTTON$COPIED",
|
||||
APP$TITLE = "APP$TITLE",
|
||||
BROWSER$TITLE = "BROWSER$TITLE",
|
||||
BROWSER$EMPTY_MESSAGE = "BROWSER$EMPTY_MESSAGE",
|
||||
@@ -24,11 +38,25 @@ export enum I18nKey {
|
||||
SUGGESTIONS$AUTO_MERGE_PRS = "SUGGESTIONS$AUTO_MERGE_PRS",
|
||||
SUGGESTIONS$FIX_README = "SUGGESTIONS$FIX_README",
|
||||
SUGGESTIONS$CLEAN_DEPENDENCIES = "SUGGESTIONS$CLEAN_DEPENDENCIES",
|
||||
SETTINGS$LLM_SETTINGS = "SETTINGS$LLM_SETTINGS",
|
||||
SETTINGS$GITHUB_SETTINGS = "SETTINGS$GITHUB_SETTINGS",
|
||||
SETTINGS$SOUND_NOTIFICATIONS = "SETTINGS$SOUND_NOTIFICATIONS",
|
||||
SETTINGS$CUSTOM_MODEL = "SETTINGS$CUSTOM_MODEL",
|
||||
GITHUB$CODE_NOT_IN_GITHUB = "GITHUB$CODE_NOT_IN_GITHUB",
|
||||
GITHUB$START_FROM_SCRATCH = "GITHUB$START_FROM_SCRATCH",
|
||||
AVATAR$ALT_TEXT = "AVATAR$ALT_TEXT",
|
||||
BRANDING$ALL_HANDS_AI = "BRANDING$ALL_HANDS_AI",
|
||||
BRANDING$ALL_HANDS_LOGO = "BRANDING$ALL_HANDS_LOGO",
|
||||
ERROR$GENERIC = "ERROR$GENERIC",
|
||||
GITHUB$AUTH_SCOPE = "GITHUB$AUTH_SCOPE",
|
||||
FILE_SERVICE$INVALID_FILE_PATH = "FILE_SERVICE$INVALID_FILE_PATH",
|
||||
WORKSPACE$PLANNER_TAB_LABEL = "WORKSPACE$PLANNER_TAB_LABEL",
|
||||
WORKSPACE$JUPYTER_TAB_LABEL = "WORKSPACE$JUPYTER_TAB_LABEL",
|
||||
WORKSPACE$CODE_EDITOR_TAB_LABEL = "WORKSPACE$CODE_EDITOR_TAB_LABEL",
|
||||
WORKSPACE$BROWSER_TAB_LABEL = "WORKSPACE$BROWSER_TAB_LABEL",
|
||||
WORKSPACE$REFRESH = "WORKSPACE$REFRESH",
|
||||
WORKSPACE$OPEN = "WORKSPACE$OPEN",
|
||||
WORKSPACE$CLOSE = "WORKSPACE$CLOSE",
|
||||
VSCODE$OPEN = "VSCODE$OPEN",
|
||||
INCREASE_TEST_COVERAGE = "INCREASE_TEST_COVERAGE",
|
||||
AUTO_MERGE_PRS = "AUTO_MERGE_PRS",
|
||||
@@ -48,6 +76,8 @@ export enum I18nKey {
|
||||
MODAL$END_SESSION_MESSAGE = "MODAL$END_SESSION_MESSAGE",
|
||||
BUTTON$END_SESSION = "BUTTON$END_SESSION",
|
||||
BUTTON$CANCEL = "BUTTON$CANCEL",
|
||||
EXIT_PROJECT$CONFIRM = "EXIT_PROJECT$CONFIRM",
|
||||
EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE",
|
||||
LANGUAGE$LABEL = "LANGUAGE$LABEL",
|
||||
GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL",
|
||||
GITHUB$TOKEN_OPTIONAL = "GITHUB$TOKEN_OPTIONAL",
|
||||
@@ -176,6 +206,12 @@ export enum I18nKey {
|
||||
LANDING$REPLAY = "LANDING$REPLAY",
|
||||
LANDING$UPLOAD_TRAJECTORY = "LANDING$UPLOAD_TRAJECTORY",
|
||||
LANDING$RECENT_CONVERSATION = "LANDING$RECENT_CONVERSATION",
|
||||
CONVERSATION$CONFIRM_DELETE = "CONVERSATION$CONFIRM_DELETE",
|
||||
CONVERSATION$METRICS_INFO = "CONVERSATION$METRICS_INFO",
|
||||
CONVERSATION$CREATED = "CONVERSATION$CREATED",
|
||||
CONVERSATION$AGO = "CONVERSATION$AGO",
|
||||
GITHUB$VSCODE_LINK_DESCRIPTION = "GITHUB$VSCODE_LINK_DESCRIPTION",
|
||||
CONVERSATION$EXIT_WARNING = "CONVERSATION$EXIT_WARNING",
|
||||
LANDING$OR = "LANDING$OR",
|
||||
SUGGESTIONS$TEST_COVERAGE = "SUGGESTIONS$TEST_COVERAGE",
|
||||
SUGGESTIONS$AUTO_MERGE = "SUGGESTIONS$AUTO_MERGE",
|
||||
@@ -216,9 +252,14 @@ export enum I18nKey {
|
||||
SETTINGS$CONFIRMATION_MODE_TOOLTIP = "SETTINGS$CONFIRMATION_MODE_TOOLTIP",
|
||||
SETTINGS$AGENT_SELECT_ENABLED = "SETTINGS$AGENT_SELECT_ENABLED",
|
||||
SETTINGS$SECURITY_ANALYZER = "SETTINGS$SECURITY_ANALYZER",
|
||||
SETTINGS$DONT_KNOW_API_KEY = "SETTINGS$DONT_KNOW_API_KEY",
|
||||
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
|
||||
SETTINGS$SAVED = "SETTINGS$SAVED",
|
||||
SETTINGS$RESET = "SETTINGS$RESET",
|
||||
PLANNER$EMPTY_MESSAGE = "PLANNER$EMPTY_MESSAGE",
|
||||
FEEDBACK$PUBLIC_LABEL = "FEEDBACK$PUBLIC_LABEL",
|
||||
FEEDBACK$PRIVATE_LABEL = "FEEDBACK$PRIVATE_LABEL",
|
||||
SIDEBAR$CONVERSATIONS = "SIDEBAR$CONVERSATIONS",
|
||||
STATUS$STARTING_RUNTIME = "STATUS$STARTING_RUNTIME",
|
||||
STATUS$STARTING_CONTAINER = "STATUS$STARTING_CONTAINER",
|
||||
STATUS$PREPARING_CONTAINER = "STATUS$PREPARING_CONTAINER",
|
||||
@@ -290,6 +331,7 @@ export enum I18nKey {
|
||||
OBSERVATION_MESSAGE$EDIT = "OBSERVATION_MESSAGE$EDIT",
|
||||
OBSERVATION_MESSAGE$WRITE = "OBSERVATION_MESSAGE$WRITE",
|
||||
OBSERVATION_MESSAGE$BROWSE = "OBSERVATION_MESSAGE$BROWSE",
|
||||
OBSERVATION_MESSAGE$RECALL = "OBSERVATION_MESSAGE$RECALL",
|
||||
EXPANDABLE_MESSAGE$SHOW_DETAILS = "EXPANDABLE_MESSAGE$SHOW_DETAILS",
|
||||
EXPANDABLE_MESSAGE$HIDE_DETAILS = "EXPANDABLE_MESSAGE$HIDE_DETAILS",
|
||||
AI_SETTINGS$TITLE = "AI_SETTINGS$TITLE",
|
||||
@@ -320,4 +362,49 @@ export enum I18nKey {
|
||||
BILLING$CLAIM_YOUR_50 = "BILLING$CLAIM_YOUR_50",
|
||||
BILLING$PROCEED_TO_STRIPE = "BILLING$PROCEED_TO_STRIPE",
|
||||
BILLING$YOURE_IN = "BILLING$YOURE_IN",
|
||||
PAYMENT$ADD_FUNDS = "PAYMENT$ADD_FUNDS",
|
||||
PAYMENT$ADD_CREDIT = "PAYMENT$ADD_CREDIT",
|
||||
PAYMENT$MANAGE_CREDITS = "PAYMENT$MANAGE_CREDITS",
|
||||
AUTH$SIGN_IN_WITH_GITHUB = "AUTH$SIGN_IN_WITH_GITHUB",
|
||||
WAITLIST$JOIN = "WAITLIST$JOIN",
|
||||
WAITLIST$IF_NOT_JOINED = "WAITLIST$IF_NOT_JOINED",
|
||||
WAITLIST$PATIENCE_MESSAGE = "WAITLIST$PATIENCE_MESSAGE",
|
||||
WAITLIST$ALMOST_THERE = "WAITLIST$ALMOST_THERE",
|
||||
PAYMENT$SUCCESS = "PAYMENT$SUCCESS",
|
||||
PAYMENT$CANCELLED = "PAYMENT$CANCELLED",
|
||||
SERVED_APP$TITLE = "SERVED_APP$TITLE",
|
||||
CONVERSATION$UNKNOWN = "CONVERSATION$UNKNOWN",
|
||||
SETTINGS$RUNTIME_OPTION_1X = "SETTINGS$RUNTIME_OPTION_1X",
|
||||
SETTINGS$RUNTIME_OPTION_2X = "SETTINGS$RUNTIME_OPTION_2X",
|
||||
SETTINGS$GET_IN_TOUCH = "SETTINGS$GET_IN_TOUCH",
|
||||
CONVERSATION$NO_METRICS = "CONVERSATION$NO_METRICS",
|
||||
CONVERSATION$DOWNLOAD_ERROR = "CONVERSATION$DOWNLOAD_ERROR",
|
||||
CONVERSATION$UPDATED = "CONVERSATION$UPDATED",
|
||||
CONVERSATION$TOTAL_COST = "CONVERSATION$TOTAL_COST",
|
||||
CONVERSATION$TOKENS_USED = "CONVERSATION$TOKENS_USED",
|
||||
CONVERSATION$INPUT = "CONVERSATION$INPUT",
|
||||
CONVERSATION$OUTPUT = "CONVERSATION$OUTPUT",
|
||||
CONVERSATION$TOTAL = "CONVERSATION$TOTAL",
|
||||
SETTINGS$RUNTIME_SETTINGS = "SETTINGS$RUNTIME_SETTINGS",
|
||||
SETTINGS$RESET_CONFIRMATION = "SETTINGS$RESET_CONFIRMATION",
|
||||
ERROR$GENERIC_OOPS = "ERROR$GENERIC_OOPS",
|
||||
ERROR$UNKNOWN = "ERROR$UNKNOWN",
|
||||
SETTINGS$FOR_OTHER_OPTIONS = "SETTINGS$FOR_OTHER_OPTIONS",
|
||||
SETTINGS$SEE_ADVANCED_SETTINGS = "SETTINGS$SEE_ADVANCED_SETTINGS",
|
||||
SETTINGS_FORM$API_KEY = "SETTINGS_FORM$API_KEY",
|
||||
SETTINGS_FORM$BASE_URL = "SETTINGS_FORM$BASE_URL",
|
||||
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
|
||||
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
||||
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
||||
ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB = "ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB",
|
||||
CONVERSATION$DELETE_WARNING = "CONVERSATION$DELETE_WARNING",
|
||||
FEEDBACK$TITLE = "FEEDBACK$TITLE",
|
||||
FEEDBACK$DESCRIPTION = "FEEDBACK$DESCRIPTION",
|
||||
EXIT_PROJECT$WARNING = "EXIT_PROJECT$WARNING",
|
||||
MODEL_SELECTOR$VERIFIED = "MODEL_SELECTOR$VERIFIED",
|
||||
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
|
||||
GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL",
|
||||
GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,3 +57,9 @@ code {
|
||||
.markdown-body td {
|
||||
padding: 0.1rem 1rem;
|
||||
}
|
||||
|
||||
/* Fast smooth scrolling for chat interface */
|
||||
.fast-smooth-scroll {
|
||||
scroll-behavior: smooth;
|
||||
scroll-timeline: 100ms;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
QueryCache,
|
||||
MutationCache,
|
||||
} from "@tanstack/react-query";
|
||||
import i18next from "i18next";
|
||||
import { I18nKey } from "./i18n/declaration";
|
||||
import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message";
|
||||
import { displayErrorToast } from "./utils/custom-toast-handlers";
|
||||
|
||||
@@ -13,8 +15,8 @@ export const queryClientConfig: QueryClientConfig = {
|
||||
if (!query.meta?.disableToast) {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
|
||||
if (!shownErrors.has(errorMessage)) {
|
||||
displayErrorToast(errorMessage || "An error occurred");
|
||||
if (!shownErrors.has(errorMessage || "")) {
|
||||
displayErrorToast(errorMessage || i18next.t(I18nKey.ERROR$GENERIC));
|
||||
shownErrors.add(errorMessage);
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -28,7 +30,7 @@ export const queryClientConfig: QueryClientConfig = {
|
||||
onError: (error, _, __, mutation) => {
|
||||
if (!mutation?.meta?.disableToast) {
|
||||
const message = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(message);
|
||||
displayErrorToast(message || i18next.t(I18nKey.ERROR$GENERIC));
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -2,15 +2,17 @@ import React from "react";
|
||||
import { useRouteError } from "react-router";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
|
||||
import { useFiles } from "#/context/files";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full border border-danger rounded-b-xl flex flex-col items-center justify-center gap-2 bg-red-500/5">
|
||||
<h1 className="text-3xl font-bold">Oops! An error occurred!</h1>
|
||||
<h1 className="text-3xl font-bold">{t("ERROR$GENERIC")}</h1>
|
||||
{error instanceof Error && <pre>{error.message}</pre>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { clearFiles, clearInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
@@ -51,6 +52,9 @@ function AppContent() {
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
|
||||
// Set the document title to the conversation title when available
|
||||
useDocumentTitleFromState();
|
||||
|
||||
const [width, setWidth] = React.useState(window.innerWidth);
|
||||
|
||||
const secrets = React.useMemo(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useSearchParams,
|
||||
} from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import i18n from "#/i18n";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
@@ -24,6 +25,7 @@ import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
return (
|
||||
@@ -41,7 +43,7 @@ export function ErrorBoundary() {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Uh oh, an error occurred!</h1>
|
||||
<h1>{t(I18nKey.ERROR$GENERIC)}</h1>
|
||||
<pre>{error.message}</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -49,7 +51,7 @@ export function ErrorBoundary() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Uh oh, an unknown error occurred!</h1>
|
||||
<h1>{t(I18nKey.ERROR$UNKNOWN)}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,7 +107,7 @@ export default function MainApp() {
|
||||
if (error?.status === 402 && pathname !== "/") {
|
||||
navigate("/");
|
||||
} else if (!isFetching && searchParams.get("free_credits") === "success") {
|
||||
displaySuccessToast(t("BILLING$YOURE_IN"));
|
||||
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
|
||||
searchParams.delete("free_credits");
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { HelpLink } from "#/components/features/settings/help-link";
|
||||
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
@@ -28,12 +30,15 @@ import {
|
||||
import { ProviderOptions } from "#/types/settings";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
// Define REMOTE_RUNTIME_OPTIONS for testing
|
||||
const REMOTE_RUNTIME_OPTIONS = [
|
||||
{ key: 1, label: "1x (2 core, 8G)" },
|
||||
{ key: 2, label: "2x (4 core, 16G)" },
|
||||
{ key: "1", label: "Standard" },
|
||||
{ key: "2", label: "Enhanced" },
|
||||
{ key: "4", label: "Premium" },
|
||||
];
|
||||
|
||||
function AccountSettings() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: settings,
|
||||
isFetching: isFetchingSettings,
|
||||
@@ -156,20 +161,21 @@ function AccountSettings() {
|
||||
SECURITY_ANALYZER:
|
||||
formData.get("security-analyzer-input")?.toString() || "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
remoteRuntimeResourceFactor ||
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
remoteRuntimeResourceFactor !== null
|
||||
? Number(remoteRuntimeResourceFactor)
|
||||
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
CONFIRMATION_MODE: confirmationModeIsEnabled,
|
||||
};
|
||||
|
||||
saveSettings(newSettings, {
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(userConsentsToAnalytics);
|
||||
displaySuccessToast("Settings saved");
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -177,7 +183,7 @@ function AccountSettings() {
|
||||
const handleReset = () => {
|
||||
saveSettings(null, {
|
||||
onSuccess: () => {
|
||||
displaySuccessToast("Settings reset");
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$RESET));
|
||||
setResetSettingsModalIsOpen(false);
|
||||
setLlmConfigMode("basic");
|
||||
},
|
||||
@@ -227,7 +233,7 @@ function AccountSettings() {
|
||||
>
|
||||
<div className="flex items-center gap-7">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
LLM Settings
|
||||
{t(I18nKey.SETTINGS$LLM_SETTINGS)}
|
||||
</h2>
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<SettingsSwitch
|
||||
@@ -235,7 +241,7 @@ function AccountSettings() {
|
||||
defaultIsToggled={isAdvancedSettingsSet}
|
||||
onToggle={onToggleAdvancedMode}
|
||||
>
|
||||
Advanced
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
</div>
|
||||
@@ -251,7 +257,7 @@ function AccountSettings() {
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label="Custom Model"
|
||||
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
|
||||
defaultValue={settings.LLM_MODEL}
|
||||
placeholder="anthropic/claude-3-5-sonnet-20241022"
|
||||
type="text"
|
||||
@@ -262,7 +268,7 @@ function AccountSettings() {
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label="Base URL"
|
||||
label={t(I18nKey.SETTINGS$BASE_URL)}
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
@@ -274,7 +280,7 @@ function AccountSettings() {
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label="API Key"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isLLMKeySet ? "<hidden>" : ""}
|
||||
@@ -287,8 +293,8 @@ function AccountSettings() {
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text="Don't know your API key?"
|
||||
linkText="Click here for instructions"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
)}
|
||||
@@ -297,7 +303,7 @@ function AccountSettings() {
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label="Agent"
|
||||
label={t(I18nKey.SETTINGS$AGENT)}
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
@@ -315,9 +321,9 @@ function AccountSettings() {
|
||||
name="runtime-settings-input"
|
||||
label={
|
||||
<>
|
||||
Runtime Settings (
|
||||
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
|
||||
<a href="mailto:contact@all-hands.dev">
|
||||
get in touch for access
|
||||
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
@@ -336,7 +342,7 @@ function AccountSettings() {
|
||||
defaultIsToggled={!!settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
>
|
||||
Enable confirmation mode
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
@@ -346,7 +352,7 @@ function AccountSettings() {
|
||||
name="enable-memory-condenser-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_DEFAULT_CONDENSER}
|
||||
>
|
||||
Enable memory condensation
|
||||
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
@@ -355,7 +361,7 @@ function AccountSettings() {
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-input"
|
||||
label="Security Analyzer"
|
||||
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
|
||||
items={
|
||||
resources?.securityAnalyzers.map((analyzer) => ({
|
||||
key: analyzer,
|
||||
@@ -373,7 +379,7 @@ function AccountSettings() {
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
Git Provider Settings
|
||||
{t(I18nKey.SETTINGS$GITHUB_SETTINGS)}
|
||||
</h2>
|
||||
{isSaas && hasAppSlug && (
|
||||
<Link
|
||||
@@ -382,7 +388,7 @@ function AccountSettings() {
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
Configure GitHub Repositories
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</BrandButton>
|
||||
</Link>
|
||||
)}
|
||||
@@ -391,7 +397,7 @@ function AccountSettings() {
|
||||
<SettingsInput
|
||||
testId="github-token-input"
|
||||
name="github-token-input"
|
||||
label="GitHub Token"
|
||||
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
@@ -403,7 +409,7 @@ function AccountSettings() {
|
||||
/>
|
||||
<p data-testid="github-token-help-anchor" className="text-xs">
|
||||
{" "}
|
||||
Generate a token on{" "}
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<b>
|
||||
{" "}
|
||||
<a
|
||||
@@ -415,7 +421,7 @@ function AccountSettings() {
|
||||
GitHub
|
||||
</a>{" "}
|
||||
</b>
|
||||
or see the{" "}
|
||||
{t(I18nKey.COMMON$HERE)}{" "}
|
||||
<b>
|
||||
<a
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
|
||||
@@ -423,7 +429,7 @@ function AccountSettings() {
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
|
||||
</a>
|
||||
</b>
|
||||
.
|
||||
@@ -432,7 +438,7 @@ function AccountSettings() {
|
||||
<SettingsInput
|
||||
testId="gitlab-token-input"
|
||||
name="gitlab-token-input"
|
||||
label="GitLab Token"
|
||||
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
@@ -443,9 +449,9 @@ function AccountSettings() {
|
||||
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
|
||||
/>
|
||||
|
||||
<p data-testId="gitlab-token-help-anchor" className="text-xs">
|
||||
<p data-testid="gitlab-token-help-anchor" className="text-xs">
|
||||
{" "}
|
||||
Generate a token on{" "}
|
||||
{t(I18nKey.GITLAB$GET_TOKEN)}{" "}
|
||||
<b>
|
||||
{" "}
|
||||
<a
|
||||
@@ -457,7 +463,7 @@ function AccountSettings() {
|
||||
GitLab
|
||||
</a>{" "}
|
||||
</b>
|
||||
or see the{" "}
|
||||
{t(I18nKey.GITLAB$OR_SEE)}{" "}
|
||||
<b>
|
||||
<a
|
||||
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
|
||||
@@ -465,7 +471,7 @@ function AccountSettings() {
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
{t(I18nKey.COMMON$DOCUMENTATION)}
|
||||
</a>
|
||||
</b>
|
||||
.
|
||||
@@ -484,13 +490,13 @@ function AccountSettings() {
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
Additional Settings
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS)}
|
||||
</h2>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="language-input"
|
||||
name="language-input"
|
||||
label="Language"
|
||||
label={t(I18nKey.SETTINGS$LANGUAGE)}
|
||||
items={AvailableLanguages.map((language) => ({
|
||||
key: language.value,
|
||||
label: language.label,
|
||||
@@ -504,7 +510,7 @@ function AccountSettings() {
|
||||
name="enable-analytics-switch"
|
||||
defaultIsToggled={!!isAnalyticsEnabled}
|
||||
>
|
||||
Enable analytics
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
@@ -512,7 +518,7 @@ function AccountSettings() {
|
||||
name="enable-sound-notifications-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
|
||||
>
|
||||
Enable sound notifications
|
||||
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
|
||||
</SettingsSwitch>
|
||||
</section>
|
||||
</div>
|
||||
@@ -524,7 +530,7 @@ function AccountSettings() {
|
||||
variant="secondary"
|
||||
onClick={() => setResetSettingsModalIsOpen(true)}
|
||||
>
|
||||
Reset to defaults
|
||||
{t(I18nKey.BUTTON$RESET_TO_DEFAULTS)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
@@ -533,7 +539,7 @@ function AccountSettings() {
|
||||
formRef.current?.requestSubmit();
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
{t(I18nKey.BUTTON$SAVE)}
|
||||
</BrandButton>
|
||||
</footer>
|
||||
|
||||
@@ -543,7 +549,7 @@ function AccountSettings() {
|
||||
data-testid="reset-modal"
|
||||
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
|
||||
>
|
||||
<p>Are you sure you want to reset all settings?</p>
|
||||
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
|
||||
<div className="w-full flex gap-2">
|
||||
<BrandButton
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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 { PathForm } from "#/components/features/served-host/path-form";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function ServedApp() {
|
||||
const { t } = useTranslation();
|
||||
const { activeHost } = useActiveHost();
|
||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||
const [currentActiveHost, setCurrentActiveHost] = React.useState<
|
||||
@@ -84,7 +87,7 @@ function ServedApp() {
|
||||
</div>
|
||||
<iframe
|
||||
key={refreshKey}
|
||||
title="Served App"
|
||||
title={t(I18nKey.SERVED_APP$TITLE)}
|
||||
src={fullUrl}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect, useSearchParams } from "react-router";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { queryClient } from "#/entry.client";
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const config = queryClient.getQueryData<GetConfigResponse>(["config"]);
|
||||
@@ -19,14 +21,15 @@ export const clientLoader = async () => {
|
||||
};
|
||||
|
||||
function BillingSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const checkoutStatus = searchParams.get("checkout");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (checkoutStatus === "success") {
|
||||
displaySuccessToast("Payment successful");
|
||||
displaySuccessToast(t(I18nKey.PAYMENT$SUCCESS));
|
||||
} else if (checkoutStatus === "cancel") {
|
||||
displayErrorToast("Payment cancelled");
|
||||
displayErrorToast(t(I18nKey.PAYMENT$CANCELLED));
|
||||
}
|
||||
|
||||
setSearchParams({});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const billingIsEnabled = config?.FEATURE_FLAGS.ENABLE_BILLING;
|
||||
@@ -15,7 +18,7 @@ function SettingsScreen() {
|
||||
>
|
||||
<header className="px-3 py-1.5 border-b border-b-tertiary flex items-center gap-2">
|
||||
<SettingsIcon width={16} height={16} />
|
||||
<h1 className="text-sm leading-6">Settings</h1>
|
||||
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
|
||||
</header>
|
||||
|
||||
{isSaas && billingIsEnabled && (
|
||||
|
||||
@@ -51,6 +51,7 @@ export function handleObservationMessage(message: ObservationMessage) {
|
||||
case ObservationType.EDIT:
|
||||
case ObservationType.THINK:
|
||||
case ObservationType.NULL:
|
||||
case ObservationType.RECALL:
|
||||
break; // We don't display the default message for these observations
|
||||
default:
|
||||
store.dispatch(addAssistantMessage(message.message));
|
||||
@@ -76,6 +77,21 @@ export function handleObservationMessage(message: ObservationMessage) {
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "recall":
|
||||
store.dispatch(
|
||||
addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation: "recall" as const,
|
||||
extras: {
|
||||
...(message.extras || {}),
|
||||
recall_type:
|
||||
(message.extras?.recall_type as
|
||||
| "workspace_context"
|
||||
| "knowledge") || "knowledge",
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "run":
|
||||
store.dispatch(
|
||||
addAssistantObservation({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
OpenHandsObservation,
|
||||
CommandObservation,
|
||||
IPythonObservation,
|
||||
RecallObservation,
|
||||
} from "#/types/core/observations";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsEventType } from "#/types/core/base";
|
||||
@@ -22,6 +23,7 @@ const HANDLED_ACTIONS: OpenHandsEventType[] = [
|
||||
"browse",
|
||||
"browse_interactive",
|
||||
"edit",
|
||||
"recall",
|
||||
];
|
||||
|
||||
function getRiskText(risk: ActionSecurityRisk) {
|
||||
@@ -112,6 +114,9 @@ export const chatSlice = createSlice({
|
||||
} else if (actionID === "browse_interactive") {
|
||||
// Include the browser_actions in the content
|
||||
text = `**Action:**\n\n\`\`\`python\n${action.payload.args.browser_actions}\n\`\`\``;
|
||||
} else if (actionID === "recall") {
|
||||
// skip recall actions
|
||||
return;
|
||||
}
|
||||
if (actionID === "run" || actionID === "run_ipython") {
|
||||
if (
|
||||
@@ -143,6 +148,73 @@ export const chatSlice = createSlice({
|
||||
if (!HANDLED_ACTIONS.includes(observationID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Special handling for RecallObservation - create a new message instead of updating an existing one
|
||||
if (observationID === "recall") {
|
||||
const recallObs = observation.payload as RecallObservation;
|
||||
let content = ``;
|
||||
|
||||
// Handle workspace context
|
||||
if (recallObs.extras.recall_type === "workspace_context") {
|
||||
if (recallObs.extras.repo_name) {
|
||||
content += `\n\n**Repository:** ${recallObs.extras.repo_name}`;
|
||||
}
|
||||
if (recallObs.extras.repo_directory) {
|
||||
content += `\n\n**Directory:** ${recallObs.extras.repo_directory}`;
|
||||
}
|
||||
if (recallObs.extras.date) {
|
||||
content += `\n\n**Date:** ${recallObs.extras.date}`;
|
||||
}
|
||||
if (
|
||||
recallObs.extras.runtime_hosts &&
|
||||
Object.keys(recallObs.extras.runtime_hosts).length > 0
|
||||
) {
|
||||
content += `\n\n**Available Hosts**`;
|
||||
for (const [host, port] of Object.entries(
|
||||
recallObs.extras.runtime_hosts,
|
||||
)) {
|
||||
content += `\n\n- ${host} (port ${port})`;
|
||||
}
|
||||
}
|
||||
if (recallObs.extras.repo_instructions) {
|
||||
content += `\n\n**Repository Instructions:**\n\n${recallObs.extras.repo_instructions}`;
|
||||
}
|
||||
if (recallObs.extras.additional_agent_instructions) {
|
||||
content += `\n\n**Additional Instructions:**\n\n${recallObs.extras.additional_agent_instructions}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new message for the observation
|
||||
// Use the correct translation ID format that matches what's in the i18n file
|
||||
const translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
|
||||
|
||||
// Handle microagent knowledge
|
||||
if (
|
||||
recallObs.extras.microagent_knowledge &&
|
||||
recallObs.extras.microagent_knowledge.length > 0
|
||||
) {
|
||||
content += `\n\n**Triggered Microagent Knowledge:**`;
|
||||
for (const knowledge of recallObs.extras.microagent_knowledge) {
|
||||
content += `\n\n- **${knowledge.name}** (triggered by keyword: ${knowledge.trigger})\n\n\`\`\`\n${knowledge.content}\n\`\`\``;
|
||||
}
|
||||
}
|
||||
|
||||
const message: Message = {
|
||||
type: "action",
|
||||
sender: "assistant",
|
||||
translationID,
|
||||
eventID: observation.payload.id,
|
||||
content,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
success: true,
|
||||
};
|
||||
|
||||
state.messages.push(message);
|
||||
return; // Skip the normal observation handling below
|
||||
}
|
||||
|
||||
// Normal handling for other observation types
|
||||
const translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
|
||||
const causeID = observation.payload.cause;
|
||||
const causeMessage = state.messages.find(
|
||||
|
||||
@@ -133,6 +133,15 @@ export interface RejectAction extends OpenHandsActionEvent<"reject"> {
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecallAction extends OpenHandsActionEvent<"recall"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
recall_type: "workspace_context" | "knowledge";
|
||||
query: string;
|
||||
thought: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenHandsAction =
|
||||
| UserMessageAction
|
||||
| AssistantMessageAction
|
||||
@@ -146,4 +155,5 @@ export type OpenHandsAction =
|
||||
| FileReadAction
|
||||
| FileEditAction
|
||||
| FileWriteAction
|
||||
| RejectAction;
|
||||
| RejectAction
|
||||
| RecallAction;
|
||||
|
||||
@@ -12,7 +12,8 @@ export type OpenHandsEventType =
|
||||
| "reject"
|
||||
| "think"
|
||||
| "finish"
|
||||
| "error";
|
||||
| "error"
|
||||
| "recall";
|
||||
|
||||
interface OpenHandsBaseEvent {
|
||||
id: number;
|
||||
|
||||
@@ -109,6 +109,26 @@ export interface AgentThinkObservation
|
||||
};
|
||||
}
|
||||
|
||||
export interface MicroagentKnowledge {
|
||||
name: string;
|
||||
trigger: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface RecallObservation extends OpenHandsObservationEvent<"recall"> {
|
||||
source: "agent";
|
||||
extras: {
|
||||
recall_type?: "workspace_context" | "knowledge";
|
||||
repo_name?: string;
|
||||
repo_directory?: string;
|
||||
repo_instructions?: string;
|
||||
runtime_hosts?: Record<string, number>;
|
||||
additional_agent_instructions?: string;
|
||||
date?: string;
|
||||
microagent_knowledge?: MicroagentKnowledge[];
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenHandsObservation =
|
||||
| AgentStateChangeObservation
|
||||
| AgentThinkObservation
|
||||
@@ -120,4 +140,5 @@ export type OpenHandsObservation =
|
||||
| WriteObservation
|
||||
| ReadObservation
|
||||
| EditObservation
|
||||
| ErrorObservation;
|
||||
| ErrorObservation
|
||||
| RecallObservation;
|
||||
|
||||
@@ -29,6 +29,9 @@ enum ObservationType {
|
||||
// A response to the agent's thought (usually a static message)
|
||||
THINK = "think",
|
||||
|
||||
// An observation that shows agent's context extension
|
||||
RECALL = "recall",
|
||||
|
||||
// A no-op observation
|
||||
NULL = "null",
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ let titleInterval: number | undefined;
|
||||
const isBrowser =
|
||||
typeof window !== "undefined" && typeof document !== "undefined";
|
||||
|
||||
// Use a constant for the notification parameter to avoid hardcoded strings
|
||||
const NOTIFICATION_PARAM = "notification";
|
||||
|
||||
export const browserTab = {
|
||||
startNotification(message: string) {
|
||||
if (!isBrowser) return;
|
||||
@@ -29,9 +32,9 @@ export const browserTab = {
|
||||
'link[rel="icon"]',
|
||||
) as HTMLLinkElement;
|
||||
if (favicon) {
|
||||
favicon.href = favicon.href.includes("?notification")
|
||||
favicon.href = favicon.href.includes(`?${NOTIFICATION_PARAM}`)
|
||||
? favicon.href
|
||||
: `${favicon.href}?notification`;
|
||||
: `${favicon.href}?${NOTIFICATION_PARAM}`;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,7 +54,7 @@ export const browserTab = {
|
||||
'link[rel="icon"]',
|
||||
) as HTMLLinkElement;
|
||||
if (favicon) {
|
||||
favicon.href = favicon.href.replace("?notification", "");
|
||||
favicon.href = favicon.href.replace(`?${NOTIFICATION_PARAM}`, "");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function downloadTrajectory(
|
||||
suggestedName: `trajectory-${conversationId}.json`,
|
||||
types: [
|
||||
{
|
||||
description: "JSON File",
|
||||
description: "JSON File", // This is a file type description, not user-facing text
|
||||
accept: {
|
||||
"application/json": [".json"],
|
||||
},
|
||||
|
||||
@@ -11,6 +11,6 @@ export const generateGitHubAuthUrl = (clientId: string, requestUrl: URL) => {
|
||||
.replace(/(^|\.)staging\.all-hands\.dev$/, "$1auth.staging.all-hands.dev")
|
||||
.replace(/(^|\.)app\.all-hands\.dev$/, "auth.app.all-hands.dev")
|
||||
.replace(/(^|\.)localhost$/, "auth.staging.all-hands.dev");
|
||||
const scope = "openid email profile";
|
||||
const scope = "openid email profile"; // OAuth scope - not user-facing
|
||||
return `https://${authUrl}/realms/allhands/protocol/openid-connect/auth?client_id=github&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// These are provider names, not user-facing text
|
||||
export const MAP_PROVIDER = {
|
||||
openai: "OpenAI",
|
||||
azure: "Azure",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export type JupyterLine = { type: "plaintext" | "image"; content: string };
|
||||
|
||||
const IMAGE_PREFIX = " => {
|
||||
const lines: JupyterLine[] = [];
|
||||
let currentText = "";
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
if (line.startsWith(") {
|
||||
if (line.startsWith(IMAGE_PREFIX)) {
|
||||
if (currentText) {
|
||||
lines.push({ type: "plaintext", content: currentText });
|
||||
currentText = ""; // Reset after pushing plaintext
|
||||
|
||||
@@ -22,5 +22,5 @@ export const retrieveAxiosErrorMessage = (error: AxiosError) => {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return errorMessage || "An error occurred";
|
||||
return errorMessage;
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -51,7 +51,7 @@ When OpenHands works with a repository, it:
|
||||
|
||||
## Types of MicroAgents
|
||||
|
||||
All microagents use markdown files with YAML frontmatter.
|
||||
Most microagents use markdown files with YAML frontmatter. For repository agents (repo.md), the frontmatter is optional - if not provided, the file will be loaded with default settings as a repository agent.
|
||||
|
||||
|
||||
### 1. Knowledge Agents
|
||||
@@ -147,6 +147,7 @@ You can see an example of a task-based agent in [OpenHands's pull request updati
|
||||
- Specify testing and build procedures
|
||||
- List environment requirements
|
||||
- Maintain up-to-date team practices
|
||||
- YAML frontmatter is optional - files without frontmatter will be loaded with default settings
|
||||
|
||||
### Submission Process
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ the GitLab API.
|
||||
You can use `curl` with the `GITLAB_TOKEN` to interact with GitLab's API.
|
||||
ALWAYS use the GitLab API for operations instead of a web browser.
|
||||
|
||||
If you encounter authentication issues when pushing to GitLab (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://${GITLAB_TOKEN}@gitlab.com/username/repo.git`
|
||||
If you encounter authentication issues when pushing to GitLab (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://oauth2:${GITLAB_TOKEN}@gitlab.com/username/repo.git`
|
||||
|
||||
Here are some instructions for pushing, but ONLY do this if the user asks you to:
|
||||
* NEVER push directly to the `main` or `master` branch
|
||||
|
||||
@@ -10,6 +10,7 @@ from litellm.exceptions import ( # noqa
|
||||
APIError,
|
||||
AuthenticationError,
|
||||
BadRequestError,
|
||||
ContentPolicyViolationError,
|
||||
ContextWindowExceededError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
@@ -250,7 +251,12 @@ class AgentController:
|
||||
self.state.last_error = err_id
|
||||
elif isinstance(e, BadRequestError) and 'ExceededBudget' in str(e):
|
||||
err_id = 'STATUS$ERROR_LLM_OUT_OF_CREDITS'
|
||||
# Set error reason for budget exceeded
|
||||
self.state.last_error = err_id
|
||||
elif isinstance(e, ContentPolicyViolationError) or (
|
||||
isinstance(e, BadRequestError)
|
||||
and 'ContentPolicyViolationError' in str(e)
|
||||
):
|
||||
err_id = 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION'
|
||||
self.state.last_error = err_id
|
||||
elif isinstance(e, RateLimitError):
|
||||
await self.set_agent_state_to(AgentState.RATE_LIMITED)
|
||||
@@ -283,6 +289,7 @@ class AgentController:
|
||||
or isinstance(e, InternalServerError)
|
||||
or isinstance(e, AuthenticationError)
|
||||
or isinstance(e, RateLimitError)
|
||||
or isinstance(e, ContentPolicyViolationError)
|
||||
or isinstance(e, LLMContextWindowExceedError)
|
||||
):
|
||||
reported = e
|
||||
@@ -490,15 +497,8 @@ class AgentController:
|
||||
|
||||
if self.get_agent_state() != AgentState.RUNNING:
|
||||
await self.set_agent_state_to(AgentState.RUNNING)
|
||||
elif action.source == EventSource.AGENT:
|
||||
# Check if we need to trigger microagents based on agent message content
|
||||
recall_action = RecallAction(
|
||||
query=action.content, recall_type=RecallType.KNOWLEDGE
|
||||
)
|
||||
self._pending_action = recall_action
|
||||
# This is source=AGENT because the agent message is the trigger for the microagent retrieval
|
||||
self.event_stream.add_event(recall_action, EventSource.AGENT)
|
||||
|
||||
elif action.source == EventSource.AGENT:
|
||||
# If the agent is waiting for a response, set the appropriate state
|
||||
if action.wait_for_response:
|
||||
await self.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
|
||||
@@ -1084,44 +1084,8 @@ class AgentController:
|
||||
# cut in half
|
||||
mid_point = max(1, len(events) // 2)
|
||||
kept_events = events[mid_point:]
|
||||
|
||||
# Handle first event in truncated history
|
||||
if kept_events:
|
||||
i = 0
|
||||
while i < len(kept_events):
|
||||
first_event = kept_events[i]
|
||||
if isinstance(first_event, Observation) and first_event.cause:
|
||||
# Find its action and include it
|
||||
matching_action = next(
|
||||
(
|
||||
e
|
||||
for e in reversed(events[:mid_point])
|
||||
if isinstance(e, Action) and e.id == first_event.cause
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching_action:
|
||||
kept_events = [matching_action] + kept_events
|
||||
else:
|
||||
self.log(
|
||||
'warning',
|
||||
f'Found Observation without matching Action at id={first_event.id}',
|
||||
)
|
||||
# drop this observation
|
||||
kept_events = kept_events[1:]
|
||||
break
|
||||
|
||||
elif isinstance(first_event, MessageAction) or (
|
||||
isinstance(first_event, Action)
|
||||
and first_event.source == EventSource.USER
|
||||
):
|
||||
# if it's a message action or a user action, keep it and continue to find the next event
|
||||
i += 1
|
||||
continue
|
||||
|
||||
else:
|
||||
# if it's an action with source == EventSource.AGENT, we're good
|
||||
break
|
||||
if len(kept_events) > 0 and isinstance(kept_events[0], Observation):
|
||||
kept_events = kept_events[1:]
|
||||
|
||||
# Ensure first user message is included
|
||||
if first_user_msg and first_user_msg not in kept_events:
|
||||
|
||||
@@ -75,6 +75,10 @@ class LLMSummarizingCondenserConfig(BaseModel):
|
||||
description='Maximum size of the condensed history before triggering forgetting.',
|
||||
ge=2,
|
||||
)
|
||||
max_event_length: int = Field(
|
||||
default=10_000,
|
||||
description='Maximum length of the event representations to be passed to the LLM.',
|
||||
)
|
||||
|
||||
model_config = {'extra': 'forbid'}
|
||||
|
||||
@@ -122,6 +126,33 @@ class LLMAttentionCondenserConfig(BaseModel):
|
||||
model_config = {'extra': 'forbid'}
|
||||
|
||||
|
||||
class StructuredSummaryCondenserConfig(BaseModel):
|
||||
"""Configuration for StructuredSummaryCondenser instances."""
|
||||
|
||||
type: Literal['structured'] = Field('structured')
|
||||
llm_config: LLMConfig = Field(
|
||||
..., description='Configuration for the LLM to use for condensing.'
|
||||
)
|
||||
|
||||
# at least one event by default, because the best guess is that it's the user task
|
||||
keep_first: int = Field(
|
||||
default=1,
|
||||
description='Number of initial events to always keep in history.',
|
||||
ge=0,
|
||||
)
|
||||
max_size: int = Field(
|
||||
default=100,
|
||||
description='Maximum size of the condensed history before triggering forgetting.',
|
||||
ge=2,
|
||||
)
|
||||
max_event_length: int = Field(
|
||||
default=10_000,
|
||||
description='Maximum length of the event representations to be passed to the LLM.',
|
||||
)
|
||||
|
||||
model_config = {'extra': 'forbid'}
|
||||
|
||||
|
||||
# Type alias for convenience
|
||||
CondenserConfig = (
|
||||
NoOpCondenserConfig
|
||||
@@ -131,6 +162,7 @@ CondenserConfig = (
|
||||
| LLMSummarizingCondenserConfig
|
||||
| AmortizedForgettingCondenserConfig
|
||||
| LLMAttentionCondenserConfig
|
||||
| StructuredSummaryCondenserConfig
|
||||
)
|
||||
|
||||
|
||||
@@ -233,6 +265,7 @@ def create_condenser_config(condenser_type: str, data: dict) -> CondenserConfig:
|
||||
'llm': LLMSummarizingCondenserConfig,
|
||||
'amortized': AmortizedForgettingCondenserConfig,
|
||||
'llm_attention': LLMAttentionCondenserConfig,
|
||||
'structured': StructuredSummaryCondenserConfig,
|
||||
}
|
||||
|
||||
if condenser_type not in condenser_classes:
|
||||
|
||||
@@ -88,6 +88,16 @@ class LLMResponseError(Exception):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# This exception should be retried
|
||||
# Typically, after retry with a non-zero temperature, the LLM will return a response
|
||||
class LLMNoResponseError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'LLM did not return a response. This is only seen in Gemini models so far.',
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UserCancelledError(Exception):
|
||||
def __init__(self, message: str = 'User cancelled the request') -> None:
|
||||
super().__init__(message)
|
||||
|
||||
@@ -119,7 +119,11 @@ def initialize_repository_for_runtime(
|
||||
if selected_repository and provider_tokens:
|
||||
logger.debug(f'Selected repository {selected_repository}.')
|
||||
repo_directory = call_async_from_sync(
|
||||
runtime.clone_repo, GENERAL_TIMEOUT, github_token, selected_repository, None
|
||||
runtime.clone_repo,
|
||||
GENERAL_TIMEOUT,
|
||||
provider_tokens,
|
||||
selected_repository,
|
||||
None,
|
||||
)
|
||||
# Run setup script if it exists
|
||||
runtime.maybe_run_setup_script()
|
||||
|
||||
@@ -7,12 +7,37 @@ from openhands.events.event import Event, EventSource
|
||||
from openhands.events.serialization.event import event_from_dict, event_to_dict
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_dir,
|
||||
get_conversation_event_filename,
|
||||
get_conversation_events_dir,
|
||||
)
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CachePage:
|
||||
events: list[dict] | None
|
||||
start: int
|
||||
end: int
|
||||
|
||||
def covers(self, global_index: int) -> bool:
|
||||
if global_index < self.start:
|
||||
return False
|
||||
if global_index >= self.end:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_event(self, global_index: int) -> Event | None:
|
||||
# If there was not actually a cached page, return None
|
||||
if not self.events:
|
||||
return None
|
||||
local_index = global_index - self.start
|
||||
return event_from_dict(self.events[local_index])
|
||||
|
||||
|
||||
_DUMMY_PAGE = _CachePage(None, 1, -1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventStore:
|
||||
"""
|
||||
@@ -23,6 +48,7 @@ class EventStore:
|
||||
file_store: FileStore
|
||||
user_id: str | None
|
||||
cur_id: int = -1 # We fix this in post init if it is not specified
|
||||
cache_size: int = 25
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.cur_id >= 0:
|
||||
@@ -83,30 +109,33 @@ class EventStore:
|
||||
return True
|
||||
return False
|
||||
|
||||
if reverse:
|
||||
if end_id is None:
|
||||
end_id = self.cur_id - 1
|
||||
event_id = end_id
|
||||
while event_id >= start_id:
|
||||
try:
|
||||
event = self.get_event(event_id)
|
||||
if not should_filter(event):
|
||||
yield event
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'No event found for ID {event_id}')
|
||||
event_id -= 1
|
||||
if end_id is None:
|
||||
end_id = self.cur_id
|
||||
else:
|
||||
event_id = start_id
|
||||
while should_continue():
|
||||
if end_id is not None and event_id > end_id:
|
||||
break
|
||||
end_id += 1 # From inclusive to exclusive
|
||||
|
||||
if reverse:
|
||||
step = -1
|
||||
start_id, end_id = end_id, start_id
|
||||
start_id -= 1
|
||||
end_id -= 1
|
||||
else:
|
||||
step = 1
|
||||
|
||||
cache_page = _DUMMY_PAGE
|
||||
for index in range(start_id, end_id, step):
|
||||
if not should_continue():
|
||||
return
|
||||
if not cache_page.covers(index):
|
||||
cache_page = self._load_cache_page_for_index(index)
|
||||
event = cache_page.get_event(index)
|
||||
if event is None:
|
||||
try:
|
||||
event = self.get_event(event_id)
|
||||
if not should_filter(event):
|
||||
yield event
|
||||
event = self.get_event(index)
|
||||
except FileNotFoundError:
|
||||
break
|
||||
event_id += 1
|
||||
event = None
|
||||
if event and not should_filter(event):
|
||||
yield event
|
||||
|
||||
def get_event(self, id: int) -> Event:
|
||||
filename = self._get_filename_for_id(id, self.user_id)
|
||||
@@ -230,6 +259,25 @@ class EventStore:
|
||||
def _get_filename_for_id(self, id: int, user_id: str | None) -> str:
|
||||
return get_conversation_event_filename(self.sid, id, user_id)
|
||||
|
||||
def _get_filename_for_cache(self, start: int, end: int) -> str:
|
||||
return f'{get_conversation_dir(self.sid, self.user_id)}event_cache/{start}-{end}.json'
|
||||
|
||||
def _load_cache_page(self, start: int, end: int) -> _CachePage:
|
||||
"""Read a page from the cache. Reading individual events is slow when there are a lot of them, so we use pages."""
|
||||
cache_filename = self._get_filename_for_cache(start, end)
|
||||
try:
|
||||
content = self.file_store.read(cache_filename)
|
||||
events = json.loads(content)
|
||||
except FileNotFoundError:
|
||||
events = None
|
||||
page = _CachePage(events, start, end)
|
||||
return page
|
||||
|
||||
def _load_cache_page_for_index(self, index: int) -> _CachePage:
|
||||
offset = index % self.cache_size
|
||||
index -= offset
|
||||
return self._load_cache_page(index, index + self.cache_size)
|
||||
|
||||
@staticmethod
|
||||
def _get_id_from_filename(filename: str) -> int:
|
||||
try:
|
||||
|
||||
@@ -52,6 +52,7 @@ class EventStream(EventStore):
|
||||
_queue_loop: asyncio.AbstractEventLoop | None
|
||||
_thread_pools: dict[str, dict[str, ThreadPoolExecutor]]
|
||||
_thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]]
|
||||
_write_page_cache: list[dict]
|
||||
|
||||
def __init__(self, sid: str, file_store: FileStore, user_id: str | None = None):
|
||||
super().__init__(sid, file_store, user_id)
|
||||
@@ -66,6 +67,7 @@ class EventStream(EventStore):
|
||||
self._subscribers = {}
|
||||
self._lock = threading.Lock()
|
||||
self.secrets = {}
|
||||
self._write_page_cache = []
|
||||
|
||||
def _init_thread_loop(self, subscriber_id: str, callback_id: str) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
@@ -171,8 +173,22 @@ class EventStream(EventStore):
|
||||
self.file_store.write(
|
||||
self._get_filename_for_id(event.id, self.user_id), json.dumps(data)
|
||||
)
|
||||
self._write_page_cache.append(data)
|
||||
self._store_cache_page()
|
||||
self._queue.put(event)
|
||||
|
||||
def _store_cache_page(self):
|
||||
"""Store a page in the cache. Reading individual events is slow when there are a lot of them, so we use pages."""
|
||||
current_write_page = self._write_page_cache
|
||||
if len(current_write_page) < self.cache_size:
|
||||
return
|
||||
self._write_page_cache = []
|
||||
start = current_write_page[0]['id']
|
||||
end = start + self.cache_size
|
||||
contents = json.dumps(current_write_page)
|
||||
cache_filename = self._get_filename_for_cache(start, end)
|
||||
self.file_store.write(cache_filename, contents)
|
||||
|
||||
def set_secrets(self, secrets: dict[str, str]) -> None:
|
||||
self.secrets = secrets.copy()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from openhands.integrations.service_types import (
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
@@ -99,31 +100,36 @@ class GitHubService(GitService):
|
||||
email=response.get('email'),
|
||||
)
|
||||
|
||||
async def get_repositories(
|
||||
self, sort: str, installation_id: int | None
|
||||
) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
all_repos: list[dict] = []
|
||||
|
||||
async def _fetch_paginated_repos(
|
||||
self, url: str, params: dict, max_repos: int, extract_key: str | None = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Fetch repositories with pagination support.
|
||||
|
||||
Args:
|
||||
url: The API endpoint URL
|
||||
params: Query parameters for the request
|
||||
max_repos: Maximum number of repositories to fetch
|
||||
extract_key: If provided, extract repositories from this key in the response
|
||||
|
||||
Returns:
|
||||
List of repository dictionaries
|
||||
"""
|
||||
repos: list[dict] = []
|
||||
page = 1
|
||||
|
||||
while len(all_repos) < MAX_REPOS:
|
||||
params = {'page': str(page), 'per_page': str(PER_PAGE)}
|
||||
if installation_id:
|
||||
url = (
|
||||
f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
)
|
||||
response, headers = await self._fetch_data(url, params)
|
||||
response = response.get('repositories', [])
|
||||
else:
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
params['sort'] = sort
|
||||
response, headers = await self._fetch_data(url, params)
|
||||
while len(repos) < max_repos:
|
||||
page_params = {**params, 'page': str(page)}
|
||||
response, headers = await self._fetch_data(url, page_params)
|
||||
|
||||
if not response: # No more repositories
|
||||
# Extract repositories from response
|
||||
page_repos = response.get(extract_key, []) if extract_key else response
|
||||
|
||||
if not page_repos: # No more repositories
|
||||
break
|
||||
|
||||
all_repos.extend(response)
|
||||
repos.extend(page_repos)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
@@ -131,8 +137,43 @@ class GitHubService(GitService):
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
# Trim to MAX_REPOS if needed and convert to Repository objects
|
||||
all_repos = all_repos[:MAX_REPOS]
|
||||
return repos[:max_repos] # Trim to max_repos if needed
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
all_repos: list[dict] = []
|
||||
|
||||
if app_mode == AppMode.SAAS:
|
||||
# Get all installation IDs and fetch repos for each one
|
||||
installation_ids = await self.get_installation_ids()
|
||||
|
||||
# Iterate through each installation ID
|
||||
for installation_id in installation_ids:
|
||||
params = {'per_page': str(PER_PAGE)}
|
||||
url = (
|
||||
f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
)
|
||||
|
||||
# Fetch repositories for this installation
|
||||
installation_repos = await self._fetch_paginated_repos(
|
||||
url, params, MAX_REPOS - len(all_repos), extract_key='repositories'
|
||||
)
|
||||
|
||||
all_repos.extend(installation_repos)
|
||||
|
||||
# If we've already reached MAX_REPOS, no need to check other installations
|
||||
if len(all_repos) >= MAX_REPOS:
|
||||
break
|
||||
else:
|
||||
# Original behavior for non-SaaS mode
|
||||
params = {'per_page': str(PER_PAGE), 'sort': sort}
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
|
||||
# Fetch user repositories
|
||||
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
|
||||
|
||||
# Convert to Repository objects
|
||||
return [
|
||||
Repository(
|
||||
id=repo.get('id'),
|
||||
@@ -318,14 +359,6 @@ class GitHubService(GitService):
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def does_repo_exist(self, repository: str) -> bool:
|
||||
url = f'{self.BASE_URL}/repos/{repository}'
|
||||
try:
|
||||
await self._fetch_data(url)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
github_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITHUB_SERVICE_CLS',
|
||||
|
||||
@@ -13,6 +13,7 @@ from openhands.integrations.service_types import (
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
@@ -120,12 +121,7 @@ class GitLabService(GitService):
|
||||
|
||||
return repos
|
||||
|
||||
async def get_repositories(
|
||||
self, sort: str, installation_id: int | None
|
||||
) -> list[Repository]:
|
||||
# if installation_id:
|
||||
# return [] # Not implementing installation_token case yet
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitLab API
|
||||
all_repos: list[dict] = []
|
||||
@@ -146,7 +142,6 @@ class GitLabService(GitService):
|
||||
'per_page': str(PER_PAGE),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'owned': 1, # Use 1 instead of True
|
||||
'membership': 1, # Use 1 instead of True
|
||||
}
|
||||
response, headers = await self._fetch_data(url, params)
|
||||
@@ -174,16 +169,6 @@ class GitLabService(GitService):
|
||||
for repo in all_repos
|
||||
]
|
||||
|
||||
async def does_repo_exist(self, repository: str) -> bool:
|
||||
encoded_repo = quote_plus(repository)
|
||||
url = f'{self.BASE_URL}/projects/{encoded_repo}'
|
||||
try:
|
||||
await self._fetch_data(url)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return False
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS',
|
||||
|
||||
@@ -14,7 +14,6 @@ from pydantic import (
|
||||
)
|
||||
from pydantic.json import pydantic_encoder
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.commands import CmdRunAction
|
||||
from openhands.events.stream import EventStream
|
||||
@@ -27,6 +26,7 @@ from openhands.integrations.service_types import (
|
||||
Repository,
|
||||
User,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class ProviderToken(BaseModel):
|
||||
@@ -188,11 +188,7 @@ class ProviderHandler:
|
||||
service = self._get_service(provider)
|
||||
return await service.get_latest_token()
|
||||
|
||||
async def get_repositories(
|
||||
self,
|
||||
sort: str,
|
||||
installation_id: int | None,
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""
|
||||
Get repositories from a selected providers with pagination support
|
||||
"""
|
||||
@@ -201,7 +197,7 @@ class ProviderHandler:
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
service_repos = await service.get_repositories(sort, installation_id)
|
||||
service_repos = await service.get_repositories(sort, app_mode)
|
||||
all_repos.extend(service_repos)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -228,32 +224,6 @@ class ProviderHandler:
|
||||
|
||||
return all_repos
|
||||
|
||||
async def get_remote_repository_url(self, repository: str) -> str | None:
|
||||
if not repository:
|
||||
return None
|
||||
|
||||
provider_domains = {
|
||||
ProviderType.GITHUB: 'github.com',
|
||||
ProviderType.GITLAB: 'gitlab.com',
|
||||
}
|
||||
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
repo_exists = await service.does_repo_exist(repository)
|
||||
if repo_exists:
|
||||
git_token = self.provider_tokens[provider].token
|
||||
if git_token and provider in provider_domains:
|
||||
domain = provider_domains[provider]
|
||||
|
||||
if provider == ProviderType.GITLAB:
|
||||
return f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repository}.git'
|
||||
|
||||
return f'https://{git_token.get_secret_value()}@{domain}/{repository}.git'
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
async def set_event_stream_secrets(
|
||||
self,
|
||||
event_stream: EventStream,
|
||||
@@ -319,9 +289,7 @@ class ProviderHandler:
|
||||
get_latest: Get the latest working token for the providers if True, otherwise get the existing ones
|
||||
"""
|
||||
|
||||
# TODO: We should remove `not get_latest` in the future. More
|
||||
# details about the error this fixes is in the next comment below
|
||||
if not self.provider_tokens and not get_latest:
|
||||
if not self.provider_tokens:
|
||||
return {}
|
||||
|
||||
env_vars: dict[ProviderType, SecretStr] = {}
|
||||
@@ -342,20 +310,6 @@ class ProviderHandler:
|
||||
if token:
|
||||
env_vars[provider] = token
|
||||
|
||||
# TODO: we have an error where reinitializing the runtime doesn't happen with
|
||||
# the provider tokens; thus the code above believes that github isn't a provider
|
||||
# when it really is. We need to share information about current providers set
|
||||
# for the user when the socket event for connect is sent
|
||||
if ProviderType.GITHUB not in env_vars and get_latest:
|
||||
logger.info(
|
||||
f'Force refresh runtime token for user: {self.external_auth_id}'
|
||||
)
|
||||
service = GithubServiceImpl(
|
||||
external_auth_id=self.external_auth_id,
|
||||
external_token_manager=self.external_token_manager,
|
||||
)
|
||||
env_vars[ProviderType.GITHUB] = await service.get_latest_token()
|
||||
|
||||
if not expose_secrets:
|
||||
return env_vars
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class ProviderType(Enum):
|
||||
GITHUB = 'github'
|
||||
@@ -86,13 +88,6 @@ class GitService(Protocol):
|
||||
"""Search for repositories"""
|
||||
...
|
||||
|
||||
async def get_repositories(
|
||||
self,
|
||||
sort: str,
|
||||
installation_id: int | None,
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def does_repo_exist(self, repository: str) -> bool:
|
||||
"""Check if a repository exists for the user"""
|
||||
|
||||
+21
-3
@@ -23,6 +23,7 @@ from litellm.exceptions import (
|
||||
from litellm.types.utils import CostPerToken, ModelResponse, Usage
|
||||
from litellm.utils import create_pretrained_tokenizer
|
||||
|
||||
from openhands.core.exceptions import LLMNoResponseError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.message import Message
|
||||
from openhands.llm.debug_mixin import DebugMixin
|
||||
@@ -37,7 +38,12 @@ from openhands.llm.retry_mixin import RetryMixin
|
||||
__all__ = ['LLM']
|
||||
|
||||
# tuple of exceptions to retry on
|
||||
LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (RateLimitError,)
|
||||
LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (
|
||||
RateLimitError,
|
||||
litellm.Timeout,
|
||||
litellm.InternalServerError,
|
||||
LLMNoResponseError,
|
||||
)
|
||||
|
||||
# cache prompt supporting models
|
||||
# remove this when we gemini and deepseek are supported
|
||||
@@ -63,6 +69,7 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
|
||||
'o1-2024-12-17',
|
||||
'o3-mini-2025-01-31',
|
||||
'o3-mini',
|
||||
'gemini-2.5-pro',
|
||||
]
|
||||
|
||||
REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
@@ -267,8 +274,12 @@ class LLM(RetryMixin, DebugMixin):
|
||||
|
||||
# if we mocked function calling, and we have tools, convert the response back to function calling format
|
||||
if mock_function_calling and mock_fncall_tools is not None:
|
||||
logger.debug(f'Response choices: {len(resp.choices)}')
|
||||
assert len(resp.choices) >= 1
|
||||
if len(resp.choices) < 1:
|
||||
raise LLMNoResponseError(
|
||||
'Response choices is less than 1 - This is only seen in Gemini models so far. Response: '
|
||||
+ str(resp)
|
||||
)
|
||||
|
||||
non_fncall_response_message = resp.choices[0].message
|
||||
fn_call_messages_with_response = (
|
||||
convert_non_fncall_messages_to_fncall_messages(
|
||||
@@ -282,6 +293,13 @@ class LLM(RetryMixin, DebugMixin):
|
||||
)
|
||||
resp.choices[0].message = fn_call_response_message
|
||||
|
||||
# Check if resp has 'choices' key with at least one item
|
||||
if not resp.get('choices') or len(resp['choices']) < 1:
|
||||
raise LLMNoResponseError(
|
||||
'Response choices is less than 1 - This is only seen in Gemini models so far. Response: '
|
||||
+ str(resp)
|
||||
)
|
||||
|
||||
message_back: str = resp['choices'][0]['message']['content'] or ''
|
||||
tool_calls: list[ChatCompletionMessageToolCall] = resp['choices'][0][
|
||||
'message'
|
||||
|
||||
@@ -5,6 +5,7 @@ from tenacity import (
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from openhands.core.exceptions import LLMNoResponseError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
@@ -35,6 +36,22 @@ class RetryMixin:
|
||||
if retry_listener:
|
||||
retry_listener(retry_state.attempt_number, num_retries)
|
||||
|
||||
# Check if the exception is LLMNoResponseError
|
||||
exception = retry_state.outcome.exception()
|
||||
if isinstance(exception, LLMNoResponseError):
|
||||
if hasattr(retry_state, 'kwargs'):
|
||||
# Only change temperature if it's zero or not set
|
||||
current_temp = retry_state.kwargs.get('temperature', 0)
|
||||
if current_temp == 0:
|
||||
retry_state.kwargs['temperature'] = 1.0
|
||||
logger.warning(
|
||||
'LLMNoResponseError detected with temperature=0, setting temperature to 1.0 for next attempt.'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'LLMNoResponseError detected with temperature={current_temp}, keeping original temperature'
|
||||
)
|
||||
|
||||
return retry(
|
||||
before_sleep=before_sleep,
|
||||
stop=stop_after_attempt(num_retries) | stop_if_should_exit(),
|
||||
|
||||
@@ -18,6 +18,9 @@ from openhands.memory.condenser.impl.observation_masking_condenser import (
|
||||
from openhands.memory.condenser.impl.recent_events_condenser import (
|
||||
RecentEventsCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.structured_summary_condenser import (
|
||||
StructuredSummaryCondenser,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'AmortizedForgettingCondenser',
|
||||
@@ -28,4 +31,5 @@ __all__ = [
|
||||
'ObservationMaskingCondenser',
|
||||
'BrowserOutputCondenser',
|
||||
'RecentEventsCondenser',
|
||||
'StructuredSummaryCondenser',
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ from openhands.core.config.condenser_config import LLMSummarizingCondenserConfig
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm import LLM
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
@@ -20,7 +21,13 @@ class LLMSummarizingCondenser(RollingCondenser):
|
||||
and newly forgotten events.
|
||||
"""
|
||||
|
||||
def __init__(self, llm: LLM, max_size: int = 100, keep_first: int = 1):
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLM,
|
||||
max_size: int = 100,
|
||||
keep_first: int = 1,
|
||||
max_event_length: int = 10_000,
|
||||
):
|
||||
if keep_first >= max_size // 2:
|
||||
raise ValueError(
|
||||
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
|
||||
@@ -32,10 +39,15 @@ class LLMSummarizingCondenser(RollingCondenser):
|
||||
|
||||
self.max_size = max_size
|
||||
self.keep_first = keep_first
|
||||
self.max_event_length = max_event_length
|
||||
self.llm = llm
|
||||
|
||||
super().__init__()
|
||||
|
||||
def _truncate(self, content: str) -> str:
|
||||
"""Truncate the content to fit within the specified maximum event length."""
|
||||
return truncate_content(content, max_chars=self.max_event_length)
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
head = view[: self.keep_first]
|
||||
target_size = self.max_size // 2
|
||||
@@ -56,40 +68,66 @@ class LLMSummarizingCondenser(RollingCondenser):
|
||||
forgotten_events.append(event)
|
||||
|
||||
# Construct prompt for summarization
|
||||
prompt = """You are maintaining state history for an LLM-based code agent. Track:
|
||||
prompt = """You are maintaining a context-aware state summary for an interactive agent. You will be given a list of events corresponding to actions taken by the agent, and the most recent previous summary if one exists. Track:
|
||||
|
||||
USER_CONTEXT: (Preserve essential user requirements, problem descriptions, and clarifications in concise form)
|
||||
USER_CONTEXT: (Preserve essential user requirements, goals, and clarifications in concise form)
|
||||
|
||||
STATE: {File paths, function signatures, data structures}
|
||||
COMPLETED: (Tasks completed so far, with brief results)
|
||||
PENDING: (Tasks that still need to be done)
|
||||
CURRENT_STATE: (Current variables, data structures, or relevant state)
|
||||
|
||||
For code-specific tasks, also include:
|
||||
CODE_STATE: {File paths, function signatures, data structures}
|
||||
TESTS: {Failing cases, error messages, outputs}
|
||||
CHANGES: {Code edits, variable updates}
|
||||
DEPS: {Dependencies, imports, external calls}
|
||||
INTENT: {Why changes were made, acceptance criteria}
|
||||
VERSION_CONTROL_STATUS: {Repository state, current branch, PR status, commit history}
|
||||
|
||||
PRIORITIZE:
|
||||
1. Capture key user requirements and constraints
|
||||
2. Maintain critical problem context
|
||||
3. Keep all sections concise
|
||||
1. Adapt tracking format to match the actual task type
|
||||
2. Capture key user requirements and goals
|
||||
3. Distinguish between completed and pending tasks
|
||||
4. Keep all sections concise and relevant
|
||||
|
||||
SKIP: {Git clones, build logs, file listings}
|
||||
SKIP: Tracking irrelevant details for the current task type
|
||||
|
||||
Example history format:
|
||||
USER_CONTEXT: Fix FITS card float representation - "0.009125" becomes "0.009124999999999999" causing comment truncation. Use Python's str() when possible while maintaining FITS compliance.
|
||||
Example formats:
|
||||
|
||||
STATE: mod_float() in card.py updated
|
||||
For code tasks:
|
||||
USER_CONTEXT: Fix FITS card float representation issue
|
||||
COMPLETED: Modified mod_float() in card.py, all tests passing
|
||||
PENDING: Create PR, update documentation
|
||||
CODE_STATE: mod_float() in card.py updated
|
||||
TESTS: test_format() passed
|
||||
CHANGES: str(val) replaces f"{val:.16G}"
|
||||
DEPS: None modified
|
||||
INTENT: Fix precision while maintaining FITS compliance"""
|
||||
VERSION_CONTROL_STATUS: Branch: fix-float-precision, Latest commit: a1b2c3d
|
||||
|
||||
For other tasks:
|
||||
USER_CONTEXT: Write 20 haikus based on coin flip results
|
||||
COMPLETED: 15 haikus written for results [T,H,T,H,T,H,T,T,H,T,H,T,H,T,H]
|
||||
PENDING: 5 more haikus needed
|
||||
CURRENT_STATE: Last flip: Heads, Haiku count: 15/20"""
|
||||
|
||||
prompt += '\n\n'
|
||||
|
||||
prompt += ('\n' + summary_event.message + '\n') if summary_event.message else ''
|
||||
# Add the previous summary if it exists. We'll always have a summary
|
||||
# event, but the types aren't precise enought to guarantee that it has a
|
||||
# message attribute.
|
||||
summary_event_content = self._truncate(
|
||||
summary_event.message if summary_event.message else ''
|
||||
)
|
||||
prompt += f'<PREVIOUS SUMMARY>\n{summary_event_content}\n</PREVIOUS SUMMARY>\n'
|
||||
|
||||
prompt += '\n\n'
|
||||
|
||||
# Add all events that are being forgotten. We use the string
|
||||
# representation defined by the event, and truncate it if necessary.
|
||||
for forgotten_event in forgotten_events:
|
||||
prompt += str(forgotten_event) + '\n\n'
|
||||
event_content = self._truncate(str(forgotten_event))
|
||||
prompt += f'<EVENT id={forgotten_event.id}>\n{event_content}\n</EVENT>\n'
|
||||
|
||||
prompt += 'Now summarize the events using the rules above.'
|
||||
|
||||
messages = [Message(role='user', content=[TextContent(text=prompt)])]
|
||||
|
||||
@@ -121,6 +159,7 @@ INTENT: Fix precision while maintaining FITS compliance"""
|
||||
llm=LLM(config=config.llm_config),
|
||||
max_size=config.max_size,
|
||||
keep_first=config.keep_first,
|
||||
max_event_length=config.max_event_length,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.core.config.condenser_config import (
|
||||
StructuredSummaryCondenserConfig,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm import LLM
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
RollingCondenser,
|
||||
View,
|
||||
)
|
||||
|
||||
|
||||
class StateSummary(BaseModel):
|
||||
"""A structured representation summarizing the state of the agent and the task."""
|
||||
|
||||
# Required core fields
|
||||
user_context: str = Field(
|
||||
default='',
|
||||
description='Essential user requirements, goals, and clarifications in concise form.',
|
||||
)
|
||||
completed_tasks: str = Field(
|
||||
default='', description='List of tasks completed so far with brief results.'
|
||||
)
|
||||
pending_tasks: str = Field(
|
||||
default='', description='List of tasks that still need to be done.'
|
||||
)
|
||||
current_state: str = Field(
|
||||
default='',
|
||||
description='Current variables, data structures, or other relevant state information.',
|
||||
)
|
||||
|
||||
# Code state fields
|
||||
files_modified: str = Field(
|
||||
default='', description='List of files that have been created or modified.'
|
||||
)
|
||||
function_changes: str = Field(
|
||||
default='', description='List of functions that have been created or modified.'
|
||||
)
|
||||
data_structures: str = Field(
|
||||
default='', description='List of key data structures in use or modified.'
|
||||
)
|
||||
|
||||
# Test status fields
|
||||
tests_written: str = Field(
|
||||
default='',
|
||||
description='Whether tests have been written for the changes. True, false, or unknown.',
|
||||
)
|
||||
tests_passing: str = Field(
|
||||
default='',
|
||||
description='Whether all tests are currently passing. True, false, or unknown.',
|
||||
)
|
||||
failing_tests: str = Field(
|
||||
default='', description='List of names or descriptions of any failing tests.'
|
||||
)
|
||||
error_messages: str = Field(
|
||||
default='', description='List of key error messages encountered.'
|
||||
)
|
||||
|
||||
# Version control fields
|
||||
branch_created: str = Field(
|
||||
default='',
|
||||
description='Whether a branch has been created for this work. True, false, or unknown.',
|
||||
)
|
||||
branch_name: str = Field(
|
||||
default='', description='Name of the current working branch if known.'
|
||||
)
|
||||
commits_made: str = Field(
|
||||
default='',
|
||||
description='Whether any commits have been made. True, false, or unknown.',
|
||||
)
|
||||
pr_created: str = Field(
|
||||
default='',
|
||||
description='Whether a pull request has been created. True, false, or unknown.',
|
||||
)
|
||||
pr_status: str = Field(
|
||||
default='',
|
||||
description="Status of any pull request: 'draft', 'open', 'merged', 'closed', or 'unknown'.",
|
||||
)
|
||||
|
||||
# Other fields
|
||||
dependencies: str = Field(
|
||||
default='',
|
||||
description='List of dependencies or imports that have been added or modified.',
|
||||
)
|
||||
other_relevant_context: str = Field(
|
||||
default='',
|
||||
description="Any other important information that doesn't fit into the categories above.",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_description(cls) -> dict[str, Any]:
|
||||
"""Description of a tool whose arguments are the fields of this class.
|
||||
|
||||
Can be given to an LLM to force structured generation.
|
||||
"""
|
||||
properties = {}
|
||||
|
||||
# Build properties dictionary from field information
|
||||
for field_name, field in cls.model_fields.items():
|
||||
description = field.description or ''
|
||||
|
||||
properties[field_name] = {'type': 'string', 'description': description}
|
||||
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'create_state_summary',
|
||||
'description': 'Creates a comprehensive summary of the current state of the interaction to preserve context when history grows too large. You must include non-empty values for user_context, completed_tasks, and pending_tasks.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': properties,
|
||||
'required': ['user_context', 'completed_tasks', 'pending_tasks'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Format the state summary in a clear way for Claude 3.7 Sonnet."""
|
||||
sections = [
|
||||
'# State Summary',
|
||||
'## Core Information',
|
||||
f'**User Context**: {self.user_context}',
|
||||
f'**Completed Tasks**: {self.completed_tasks}',
|
||||
f'**Pending Tasks**: {self.pending_tasks}',
|
||||
f'**Current State**: {self.current_state}',
|
||||
'## Code Changes',
|
||||
f'**Files Modified**: {self.files_modified}',
|
||||
f'**Function Changes**: {self.function_changes}',
|
||||
f'**Data Structures**: {self.data_structures}',
|
||||
f'**Dependencies**: {self.dependencies}',
|
||||
'## Testing Status',
|
||||
f'**Tests Written**: {self.tests_written}',
|
||||
f'**Tests Passing**: {self.tests_passing}',
|
||||
f'**Failing Tests**: {self.failing_tests}',
|
||||
f'**Error Messages**: {self.error_messages}',
|
||||
'## Version Control',
|
||||
f'**Branch Created**: {self.branch_created}',
|
||||
f'**Branch Name**: {self.branch_name}',
|
||||
f'**Commits Made**: {self.commits_made}',
|
||||
f'**PR Created**: {self.pr_created}',
|
||||
f'**PR Status**: {self.pr_status}',
|
||||
'## Additional Context',
|
||||
f'**Other Relevant Context**: {self.other_relevant_context}',
|
||||
]
|
||||
|
||||
# Join all sections with double newlines
|
||||
return '\n\n'.join(sections)
|
||||
|
||||
|
||||
class StructuredSummaryCondenser(RollingCondenser):
|
||||
"""A condenser that summarizes forgotten events.
|
||||
|
||||
Maintains a condensed history and forgets old events when it grows too large. Uses structured generation via function-calling to produce summaries that replace forgotten events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLM,
|
||||
max_size: int = 100,
|
||||
keep_first: int = 1,
|
||||
max_event_length: int = 10_000,
|
||||
):
|
||||
if keep_first >= max_size // 2:
|
||||
raise ValueError(
|
||||
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
|
||||
)
|
||||
if keep_first < 0:
|
||||
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
|
||||
if max_size < 1:
|
||||
raise ValueError(f'max_size ({max_size}) cannot be non-positive')
|
||||
|
||||
if not llm.is_function_calling_active():
|
||||
raise ValueError(
|
||||
'LLM must support function calling to use StructuredSummaryCondenser'
|
||||
)
|
||||
|
||||
self.max_size = max_size
|
||||
self.keep_first = keep_first
|
||||
self.max_event_length = max_event_length
|
||||
self.llm = llm
|
||||
|
||||
super().__init__()
|
||||
|
||||
def _truncate(self, content: str) -> str:
|
||||
"""Truncate the content to fit within the specified maximum event length."""
|
||||
return truncate_content(content, max_chars=self.max_event_length)
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
head = view[: self.keep_first]
|
||||
target_size = self.max_size // 2
|
||||
# Number of events to keep from the tail -- target size, minus however many
|
||||
# prefix events from the head, minus one for the summarization event
|
||||
events_from_tail = target_size - len(head) - 1
|
||||
|
||||
summary_event = (
|
||||
view[self.keep_first]
|
||||
if isinstance(view[self.keep_first], AgentCondensationObservation)
|
||||
else AgentCondensationObservation('No events summarized')
|
||||
)
|
||||
|
||||
# Identify events to be forgotten (those not in head or tail)
|
||||
forgotten_events = []
|
||||
for event in view[self.keep_first : -events_from_tail]:
|
||||
if not isinstance(event, AgentCondensationObservation):
|
||||
forgotten_events.append(event)
|
||||
|
||||
# Construct prompt for summarization
|
||||
prompt = """You are maintaining a context-aware state summary for an interactive software agent. This summary is critical because it:
|
||||
1. Preserves essential context when conversation history grows too large
|
||||
2. Prevents lost work when the session length exceeds token limits
|
||||
3. Helps maintain continuity across multiple interactions
|
||||
|
||||
You will be given:
|
||||
- A list of events (actions taken by the agent)
|
||||
- The most recent previous summary (if one exists)
|
||||
|
||||
Capture all relevant information, especially:
|
||||
- User requirements that were explicitly stated
|
||||
- Work that has been completed
|
||||
- Tasks that remain pending
|
||||
- Current state of code, variables, and data structures
|
||||
- The status of any version control operations"""
|
||||
|
||||
prompt += '\n\n'
|
||||
|
||||
# Add the previous summary if it exists. We'll always have a summary
|
||||
# event, but the types aren't precise enought to guarantee that it has a
|
||||
# message attribute.
|
||||
summary_event_content = self._truncate(
|
||||
summary_event.message if summary_event.message else ''
|
||||
)
|
||||
prompt += f'<PREVIOUS SUMMARY>\n{summary_event_content}\n</PREVIOUS SUMMARY>\n'
|
||||
|
||||
prompt += '\n\n'
|
||||
|
||||
# Add all events that are being forgotten. We use the string
|
||||
# representation defined by the event, and truncate it if necessary.
|
||||
for forgotten_event in forgotten_events:
|
||||
event_content = self._truncate(str(forgotten_event))
|
||||
prompt += f'<EVENT id={forgotten_event.id}>\n{event_content}\n</EVENT>\n'
|
||||
|
||||
messages = [Message(role='user', content=[TextContent(text=prompt)])]
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=self.llm.format_messages_for_llm(messages),
|
||||
tools=[StateSummary.tool_description()],
|
||||
tool_choice={
|
||||
'type': 'function',
|
||||
'function': {'name': 'create_state_summary'},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Extract the message containing tool calls
|
||||
message = response.choices[0].message
|
||||
|
||||
# Check if there are tool calls
|
||||
if not hasattr(message, 'tool_calls') or not message.tool_calls:
|
||||
raise ValueError('No tool calls found in response')
|
||||
|
||||
# Find the create_state_summary tool call
|
||||
summary_tool_call = None
|
||||
for tool_call in message.tool_calls:
|
||||
if tool_call.function.name == 'create_state_summary':
|
||||
summary_tool_call = tool_call
|
||||
break
|
||||
|
||||
if not summary_tool_call:
|
||||
raise ValueError('create_state_summary tool call not found')
|
||||
|
||||
# Parse the arguments
|
||||
args_json = summary_tool_call.function.arguments
|
||||
args_dict = json.loads(args_json)
|
||||
|
||||
# Create a StateSummary object
|
||||
summary = StateSummary.model_validate(args_dict)
|
||||
|
||||
except (ValueError, AttributeError, KeyError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
f'Failed to parse summary tool call: {e}. Using empty summary.'
|
||||
)
|
||||
summary = StateSummary()
|
||||
|
||||
self.add_metadata('response', response.model_dump())
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
return Condensation(
|
||||
action=CondensationAction(
|
||||
forgotten_events_start_id=min(event.id for event in forgotten_events),
|
||||
forgotten_events_end_id=max(event.id for event in forgotten_events),
|
||||
summary=str(summary),
|
||||
summary_offset=self.keep_first,
|
||||
)
|
||||
)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return len(view) > self.max_size
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: StructuredSummaryCondenserConfig
|
||||
) -> StructuredSummaryCondenser:
|
||||
return StructuredSummaryCondenser(
|
||||
llm=LLM(config=config.llm_config),
|
||||
max_size=config.max_size,
|
||||
keep_first=config.keep_first,
|
||||
max_event_length=config.max_event_length,
|
||||
)
|
||||
|
||||
|
||||
StructuredSummaryCondenser.register_config(StructuredSummaryCondenserConfig)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user