mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
feat: Settings page (#357)
* feat: Settings page * fix build: minor syntax error * rm old GH page, settings page functionality + dark mode * Fix build add suspense to settings page * add no GH installation functionality * rm comments, add cn utilities * add plus install new github org button to settings page * cr * cr * cr * cr --------- Co-authored-by: Dylan Boudro <121908331+starmorph@users.noreply.github.com> Co-authored-by: starmorph <dylan@starmorph.com>
This commit is contained in:
parent
58ab44f515
commit
3b7dee85f7
26 changed files with 1202 additions and 588 deletions
|
|
@ -58,7 +58,7 @@ Before starting, ensure you have the following installed:
|
|||
|
||||
```bash Environment Variables [expandable]
|
||||
# GitHub App OAuth settings (will be filled after creating GitHub App)
|
||||
GITHUB_APP_CLIENT_ID=""
|
||||
NEXT_PUBLIC_GITHUB_APP_ID=""
|
||||
GITHUB_APP_CLIENT_SECRET=""
|
||||
GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
|
||||
|
||||
|
|
@ -189,7 +189,7 @@ Before starting, ensure you have the following installed:
|
|||
|
||||
- **GITHUB_APP_NAME**: The name you chose
|
||||
- **GITHUB_APP_ID**: Found in the "About" section (e.g., `12345678`)
|
||||
- **GITHUB_APP_CLIENT_ID**: Found in the "About" section
|
||||
- **NEXT_PUBLIC_GITHUB_APP_ID**: Found in the "About" section
|
||||
- **GITHUB_APP_CLIENT_SECRET**:
|
||||
1. Scroll to "Client secrets" section
|
||||
2. Click "Generate new client secret"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "../../../utils/github/issue-messages.js";
|
||||
import { getBranchName } from "../../../utils/github/git.js";
|
||||
import { getDefaultHeaders } from "../../../utils/default-headers.js";
|
||||
import { getCustomConfigurableFields } from "../../../utils/config.js";
|
||||
|
||||
/**
|
||||
* Create new manager session.
|
||||
|
|
@ -87,6 +88,7 @@ ${ISSUE_CONTENT_CLOSE_TAG}`,
|
|||
},
|
||||
config: {
|
||||
recursion_limit: 400,
|
||||
configurable: getCustomConfigurableFields(config),
|
||||
},
|
||||
ifNotExists: "create",
|
||||
streamResumable: true,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createLogger, LogLevel } from "../../../utils/logger.js";
|
|||
import { getBranchName } from "../../../utils/github/git.js";
|
||||
import { PlannerGraphUpdate } from "@open-swe/shared/open-swe/planner/types";
|
||||
import { getDefaultHeaders } from "../../../utils/default-headers.js";
|
||||
import { getCustomConfigurableFields } from "../../../utils/config.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "StartPlanner");
|
||||
|
||||
|
|
@ -43,6 +44,7 @@ export async function startPlanner(
|
|||
input: runInput,
|
||||
config: {
|
||||
recursion_limit: 400,
|
||||
configurable: getCustomConfigurableFields(config),
|
||||
},
|
||||
ifNotExists: "create",
|
||||
multitaskStrategy: "enqueue",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export async function createIssueFieldsFromMessages(
|
|||
messages: BaseMessage[],
|
||||
configurable: GraphConfig["configurable"],
|
||||
): Promise<{ title: string; body: string }> {
|
||||
const model = await loadModel({ configurable }, Task.SUMMARIZER);
|
||||
const model = await loadModel({ configurable }, Task.ROUTER);
|
||||
const githubIssueTool = {
|
||||
name: "create_github_issue",
|
||||
description: "Create a new GitHub issue with the given title and body.",
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import {
|
|||
PlannerGraphState,
|
||||
PlannerGraphStateObj,
|
||||
} from "@open-swe/shared/open-swe/planner/types";
|
||||
import {
|
||||
GraphConfig,
|
||||
GraphConfiguration,
|
||||
} from "@open-swe/shared/open-swe/types";
|
||||
import { GraphConfiguration } from "@open-swe/shared/open-swe/types";
|
||||
import {
|
||||
generateAction,
|
||||
generatePlan,
|
||||
|
|
@ -22,20 +19,10 @@ import { diagnoseError } from "../shared/diagnose-error.js";
|
|||
|
||||
function takeActionOrGeneratePlan(
|
||||
state: PlannerGraphState,
|
||||
config: GraphConfig,
|
||||
): "take-plan-actions" | "generate-plan" {
|
||||
const { messages } = state;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
// If the last message is a tool call, and we have executed less than 75 actions, take action.
|
||||
// Max actions count is calculated as: maxContextActions * 2 + 1
|
||||
// This is because each action generates 2 messages (AI request + tool result) plus 1 initial human message
|
||||
const maxContextActions = config.configurable?.maxContextActions ?? 75;
|
||||
const maxActionsCount = maxContextActions * 2 + 1;
|
||||
if (
|
||||
isAIMessage(lastMessage) &&
|
||||
lastMessage.tool_calls?.length &&
|
||||
messages.length < maxActionsCount
|
||||
) {
|
||||
if (isAIMessage(lastMessage) && lastMessage.tool_calls?.length) {
|
||||
return "take-plan-actions";
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +37,7 @@ const workflow = new StateGraph(PlannerGraphStateObj, GraphConfiguration)
|
|||
.addNode("initialize-sandbox", initializeSandbox)
|
||||
.addNode("generate-plan-context-action", generateAction)
|
||||
.addNode("take-plan-actions", takeActions, {
|
||||
ends: ["generate-plan-context-action", "diagnose-error"],
|
||||
ends: ["generate-plan-context-action", "diagnose-error", "generate-plan"],
|
||||
})
|
||||
.addNode("generate-plan", generatePlan)
|
||||
.addNode("notetaker", notetaker)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
CustomNodeEvent,
|
||||
} from "@open-swe/shared/open-swe/custom-node-events";
|
||||
import { getDefaultHeaders } from "../../../utils/default-headers.js";
|
||||
import { getCustomConfigurableFields } from "../../../utils/config.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "ProposedPlan");
|
||||
|
||||
|
|
@ -100,6 +101,7 @@ async function startProgrammerRun(input: {
|
|||
input: runInput,
|
||||
config: {
|
||||
recursion_limit: 400,
|
||||
configurable: getCustomConfigurableFields(config),
|
||||
},
|
||||
ifNotExists: "create",
|
||||
streamResumable: true,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { isAIMessage, ToolMessage } from "@langchain/core/messages";
|
||||
import {
|
||||
isAIMessage,
|
||||
isToolMessage,
|
||||
ToolMessage,
|
||||
} from "@langchain/core/messages";
|
||||
import {
|
||||
createGetURLContentTool,
|
||||
createShellTool,
|
||||
|
|
@ -25,6 +29,7 @@ import { getMcpTools } from "../../../utils/mcp-client.js";
|
|||
import { getSandboxWithErrorHandling } from "../../../utils/sandbox.js";
|
||||
import { shouldDiagnoseError } from "../../../utils/tool-message-error.js";
|
||||
import { Command } from "@langchain/langgraph";
|
||||
import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "TakeAction");
|
||||
|
||||
|
|
@ -183,11 +188,6 @@ ${tc.content}`,
|
|||
})),
|
||||
});
|
||||
|
||||
const shouldRouteDiagnoseNode = shouldDiagnoseError([
|
||||
...state.messages,
|
||||
...toolCallResults,
|
||||
]);
|
||||
|
||||
const commandUpdate: PlannerGraphUpdate = {
|
||||
messages: toolCallResults,
|
||||
sandboxSessionId: sandbox.id,
|
||||
|
|
@ -195,6 +195,30 @@ ${tc.content}`,
|
|||
...(dependenciesInstalled !== null && { dependenciesInstalled }),
|
||||
};
|
||||
|
||||
const maxContextActions = config.configurable?.maxContextActions ?? 75;
|
||||
const maxActionsCount = maxContextActions * 2;
|
||||
// Exclude hidden messages, and messages that are not AI messages or tool messages.
|
||||
const filteredMessages = filterHiddenMessages([
|
||||
...state.messages,
|
||||
...(commandUpdate.messages ?? []),
|
||||
]).filter((m) => isAIMessage(m) || isToolMessage(m));
|
||||
if (filteredMessages.length >= maxActionsCount) {
|
||||
// If we've exceeded the max actions count, we should generate a plan.
|
||||
logger.info("Exceeded max actions count, generating plan.", {
|
||||
maxActionsCount,
|
||||
filteredMessages,
|
||||
});
|
||||
return new Command({
|
||||
goto: "generate-plan",
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
const shouldRouteDiagnoseNode = shouldDiagnoseError([
|
||||
...state.messages,
|
||||
...toolCallResults,
|
||||
]);
|
||||
|
||||
return new Command({
|
||||
goto: shouldRouteDiagnoseNode
|
||||
? "diagnose-error"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import {
|
|||
ReviewerGraphState,
|
||||
ReviewerGraphStateObj,
|
||||
} from "@open-swe/shared/open-swe/reviewer/types";
|
||||
import {
|
||||
GraphConfig,
|
||||
GraphConfiguration,
|
||||
} from "@open-swe/shared/open-swe/types";
|
||||
import { GraphConfiguration } from "@open-swe/shared/open-swe/types";
|
||||
import {
|
||||
finalReview,
|
||||
generateReviewActions,
|
||||
|
|
@ -18,18 +15,11 @@ import { diagnoseError } from "../shared/diagnose-error.js";
|
|||
|
||||
function takeReviewActionsOrFinalReview(
|
||||
state: ReviewerGraphState,
|
||||
config: GraphConfig,
|
||||
): "take-review-actions" | "final-review" {
|
||||
const { reviewerMessages } = state;
|
||||
const lastMessage = reviewerMessages[reviewerMessages.length - 1];
|
||||
|
||||
const maxReviewActions = config.configurable?.maxReviewActions ?? 30;
|
||||
const maxActionsCount = maxReviewActions * 2 + 1;
|
||||
if (
|
||||
isAIMessage(lastMessage) &&
|
||||
lastMessage.tool_calls?.length &&
|
||||
reviewerMessages.length < maxActionsCount
|
||||
) {
|
||||
if (isAIMessage(lastMessage) && lastMessage.tool_calls?.length) {
|
||||
return "take-review-actions";
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +31,11 @@ const workflow = new StateGraph(ReviewerGraphStateObj, GraphConfiguration)
|
|||
.addNode("initialize-state", initializeState)
|
||||
.addNode("generate-review-actions", generateReviewActions)
|
||||
.addNode("take-review-actions", takeReviewerActions, {
|
||||
ends: ["generate-review-actions", "diagnose-reviewer-error"],
|
||||
ends: [
|
||||
"generate-review-actions",
|
||||
"diagnose-reviewer-error",
|
||||
"final-review",
|
||||
],
|
||||
})
|
||||
.addNode("diagnose-reviewer-error", diagnoseError)
|
||||
.addNode("final-review", finalReview)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { isAIMessage, ToolMessage } from "@langchain/core/messages";
|
||||
import {
|
||||
isAIMessage,
|
||||
isToolMessage,
|
||||
ToolMessage,
|
||||
} from "@langchain/core/messages";
|
||||
import {
|
||||
createInstallDependenciesTool,
|
||||
createShellTool,
|
||||
|
|
@ -22,6 +26,7 @@ import { getRepoAbsolutePath } from "@open-swe/shared/git";
|
|||
import { getSandboxWithErrorHandling } from "../../../utils/sandbox.js";
|
||||
import { Command } from "@langchain/langgraph";
|
||||
import { shouldDiagnoseError } from "../../../utils/tool-message-error.js";
|
||||
import { filterHiddenMessages } from "../../../utils/message/filter-hidden.js";
|
||||
|
||||
const logger = createLogger(LogLevel.INFO, "TakeReviewAction");
|
||||
|
||||
|
|
@ -174,6 +179,25 @@ export async function takeReviewerActions(
|
|||
}),
|
||||
};
|
||||
|
||||
const maxReviewActions = config.configurable?.maxReviewActions ?? 30;
|
||||
const maxActionsCount = maxReviewActions * 2;
|
||||
// Exclude hidden messages, and messages that are not AI messages or tool messages.
|
||||
const filteredMessages = filterHiddenMessages([
|
||||
...state.reviewerMessages,
|
||||
...(commandUpdate.reviewerMessages ?? []),
|
||||
]).filter((m) => isAIMessage(m) || isToolMessage(m));
|
||||
// If we've reached the max allowed review actions, go to final review.
|
||||
if (filteredMessages.length >= maxActionsCount) {
|
||||
logger.info("Exceeded max actions count, going to final review.", {
|
||||
maxActionsCount,
|
||||
filteredMessages,
|
||||
});
|
||||
return new Command({
|
||||
goto: "final-review",
|
||||
update: commandUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
const shouldRouteDiagnoseNode = shouldDiagnoseError([
|
||||
...state.reviewerMessages,
|
||||
...toolCallResults,
|
||||
|
|
|
|||
25
apps/open-swe/src/utils/config.ts
Normal file
25
apps/open-swe/src/utils/config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
GraphConfig,
|
||||
GraphConfigurationMetadata,
|
||||
} from "@open-swe/shared/open-swe/types";
|
||||
|
||||
export function getCustomConfigurableFields(
|
||||
config: GraphConfig,
|
||||
): Partial<GraphConfig["configurable"]> {
|
||||
if (!config.configurable) return {};
|
||||
|
||||
const result: Partial<GraphConfig["configurable"]> = {};
|
||||
|
||||
for (const [key, metadataValue] of Object.entries(
|
||||
GraphConfigurationMetadata,
|
||||
)) {
|
||||
if (key in config.configurable) {
|
||||
if (metadataValue.x_open_swe_ui_config.type !== "hidden") {
|
||||
result[key as keyof GraphConfig["configurable"]] =
|
||||
config.configurable[key as keyof GraphConfig["configurable"]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# ------------------Github App Secrets-----------------
|
||||
# GitHub app client secrets. Used for the GitHub OAuth login flow.
|
||||
GITHUB_APP_CLIENT_ID=""
|
||||
NEXT_PUBLIC_GITHUB_APP_ID=""
|
||||
GITHUB_APP_CLIENT_SECRET=""
|
||||
# Should be updated to your production URL when deployed.
|
||||
# This value should match the redirect URL you have configured in
|
||||
|
|
|
|||
28
apps/web/src/app/(v2)/settings/page.tsx
Normal file
28
apps/web/src/app/(v2)/settings/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import SettingsPage from "@/features/settings-page/index";
|
||||
|
||||
function SettingsPageLoading() {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-foreground mb-2 text-3xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground">Loading your settings...</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted h-20 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-40 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-60 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<SettingsPageLoading />}>
|
||||
<SettingsPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export async function GET(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
const clientId = process.env.GITHUB_APP_CLIENT_ID;
|
||||
const clientId = process.env.NEXT_PUBLIC_GITHUB_APP_ID;
|
||||
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
|
||||
const redirectUri = process.env.GITHUB_APP_REDIRECT_URI;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const clientId = process.env.GITHUB_APP_CLIENT_ID;
|
||||
const clientId = process.env.NEXT_PUBLIC_GITHUB_APP_ID;
|
||||
const redirectUri = process.env.GITHUB_APP_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useGitHubApp } from "@/hooks/useGitHubApp";
|
||||
import { GitHubAppProvider } from "@/providers/GitHubApp";
|
||||
import { InstallationSelector } from "@/components/github/installation-selector";
|
||||
import { GithubPageLoading } from "@/components/github/github-page-loading";
|
||||
|
||||
function GitHubPageContent() {
|
||||
const { repositories, isLoading, error, isInstalled, refreshRepositories } =
|
||||
useGitHubApp();
|
||||
|
||||
const handleInstall = async () => {
|
||||
window.location.href = "/api/github/installation";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">GitHub Repository Access</h1>
|
||||
{isInstalled && (
|
||||
<div className="flex items-center gap-4">
|
||||
<InstallationSelector />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isInstalled ? (
|
||||
<div className="mb-6 rounded-lg bg-white p-6 shadow-md dark:bg-gray-900">
|
||||
<h2 className="mb-4 text-xl font-semibold">Install GitHub App</h2>
|
||||
<p className="mb-4">
|
||||
To access your GitHub repositories, you need to install our GitHub
|
||||
App and grant it access to the repositories you want to use.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="rounded bg-black px-4 py-2 text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
|
||||
>
|
||||
Install GitHub App
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 rounded-lg bg-white p-6 shadow-md dark:bg-gray-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Your Repositories</h2>
|
||||
<button
|
||||
onClick={refreshRepositories}
|
||||
disabled={isLoading}
|
||||
className="rounded bg-gray-200 px-4 py-2 hover:bg-gray-300 disabled:opacity-50 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
>
|
||||
{isLoading ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{repositories.length === 0 && !isLoading ? (
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
No repositories found. Make sure you've granted access to at least
|
||||
one repository for the selected organization.
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Loading repositories...
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{repositories.map((repo) => (
|
||||
<li
|
||||
key={repo.id}
|
||||
className="py-4"
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
<a
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{repo.full_name}
|
||||
</a>
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{repo.description || "No description"}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="mr-4">
|
||||
{repo.private ? "Private" : "Public"}
|
||||
</span>
|
||||
<span>Default branch: {repo.default_branch}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg bg-white p-6 shadow-md dark:bg-gray-900">
|
||||
<h2 className="mb-4 text-xl font-semibold">Manage GitHub App</h2>
|
||||
<p className="mb-4 text-gray-600 dark:text-gray-400">
|
||||
You can manage your GitHub App installation, including adding or
|
||||
removing repositories, through GitHub.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/settings/installations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block rounded bg-gray-200 px-4 py-2 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
Manage on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitHubPage() {
|
||||
return (
|
||||
<Suspense fallback={<GithubPageLoading />}>
|
||||
<GitHubAppProvider>
|
||||
<GitHubPageContent />
|
||||
</GitHubAppProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConfigSectionProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConfigSection({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
action,
|
||||
}: ConfigSectionProps) {
|
||||
return (
|
||||
<div className={cn("mb-6", className)}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, ForwardedRef, useState, useEffect } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ConfigField } from "@/components/configuration-sidebar/config-field";
|
||||
import { ConfigSection } from "@/components/configuration-sidebar/config-section";
|
||||
import { useConfigStore, DEFAULT_CONFIG_KEY } from "@/hooks/useConfigStore";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { ConfigurableFieldUIMetadata } from "@open-swe/shared/configurable-metadata";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelRightOpen } from "lucide-react";
|
||||
import { GraphConfigurationMetadata } from "@open-swe/shared/open-swe/types";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
|
||||
/**
|
||||
* Extract configuration metadata from the GraphConfiguration Zod schema
|
||||
*/
|
||||
function extractConfigurationsFromSchema(
|
||||
configurable: Record<string, any>,
|
||||
): ConfigurableFieldUIMetadata[] {
|
||||
const configurations: ConfigurableFieldUIMetadata[] = [];
|
||||
|
||||
for (const [label, { x_open_swe_ui_config: metadata }] of Object.entries(
|
||||
GraphConfigurationMetadata,
|
||||
)) {
|
||||
if (metadata.type === "hidden") {
|
||||
continue;
|
||||
}
|
||||
configurations.push({
|
||||
label,
|
||||
type: metadata.type,
|
||||
default: configurable[label] || metadata.default,
|
||||
description: metadata.description,
|
||||
placeholder: metadata.placeholder,
|
||||
options: metadata.options,
|
||||
min: metadata.min,
|
||||
max: metadata.max,
|
||||
step: metadata.step,
|
||||
});
|
||||
}
|
||||
|
||||
return configurations;
|
||||
}
|
||||
|
||||
export interface AIConfigPanelProps {
|
||||
className?: string;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ConfigurationSidebar = forwardRef<
|
||||
HTMLDivElement,
|
||||
AIConfigPanelProps
|
||||
>(({ className, open, onClose }, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { configs, updateConfig } = useConfigStore();
|
||||
const stream = useStreamContext();
|
||||
|
||||
const [threadId] = useQueryState("threadId");
|
||||
|
||||
const [configurations, setConfigurations] = useState<
|
||||
ConfigurableFieldUIMetadata[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const configKey = threadId || DEFAULT_CONFIG_KEY;
|
||||
|
||||
if (threadId) {
|
||||
stream.client.threads.get(threadId).then((t) => {
|
||||
if (
|
||||
!("config" in t) ||
|
||||
!(t as any).config ||
|
||||
!(t as any).config.configurable
|
||||
) {
|
||||
console.error("Thread does not have config key", t);
|
||||
return;
|
||||
}
|
||||
|
||||
const actualConfigs = extractConfigurationsFromSchema(
|
||||
(t.config as any).configurable,
|
||||
);
|
||||
actualConfigs.forEach((c) => {
|
||||
// Always update the config store with either the default values, or the values from the thread.
|
||||
updateConfig(configKey, c.label, c.default);
|
||||
});
|
||||
|
||||
setConfigurations(actualConfigs);
|
||||
});
|
||||
} else {
|
||||
const actualConfigs = extractConfigurationsFromSchema({});
|
||||
actualConfigs.forEach((c) => {
|
||||
updateConfig(configKey, c.label, c.default);
|
||||
});
|
||||
setConfigurations(actualConfigs);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [threadId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 right-0 z-10 h-screen border-l border-gray-200 bg-white shadow-lg transition-all duration-300",
|
||||
open ? "w-80 md:w-xl" : "w-0 overflow-hidden border-l-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{open && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-gray-200 p-4">
|
||||
<h2 className="text-lg font-semibold">Agent Configuration</h2>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 hover:bg-gray-100"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="general"
|
||||
className="flex flex-1 flex-col overflow-y-auto"
|
||||
>
|
||||
<TabsList className="flex-shrink-0 justify-start bg-transparent px-4 pt-2">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-y-auto">
|
||||
<TabsContent
|
||||
value="general"
|
||||
className="m-0 p-4"
|
||||
>
|
||||
<ConfigSection title="Configuration">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
configurations.map(
|
||||
(c: ConfigurableFieldUIMetadata, index: number) => (
|
||||
<ConfigField
|
||||
key={`${c.label}-${index}`}
|
||||
id={c.label}
|
||||
label={c.label}
|
||||
type={
|
||||
c.type === "boolean" ? "switch" : (c.type ?? "text")
|
||||
}
|
||||
description={c.description}
|
||||
placeholder={c.placeholder}
|
||||
options={c.options}
|
||||
min={c.min}
|
||||
max={c.max}
|
||||
step={c.step}
|
||||
/>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</ConfigSection>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ConfigurationSidebar.displayName = "ConfigurationSidebar";
|
||||
|
|
@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
|
|||
import _ from "lodash";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { BasicMarkdownText } from "../thread/markdown-text";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
|
|
@ -63,8 +64,6 @@ export function ConfigField({
|
|||
value: externalValue, // TODO: Rename to avoid conflict
|
||||
setValue: externalSetValue, // TODO: Rename to avoid conflict
|
||||
}: ConfigFieldProps) {
|
||||
const [threadId] = useQueryState("threadId");
|
||||
const configKey = threadId || DEFAULT_CONFIG_KEY;
|
||||
const store = useConfigStore();
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -73,14 +72,14 @@ export function ConfigField({
|
|||
|
||||
const currentValue = isExternallyManaged
|
||||
? externalValue
|
||||
: store.configs[configKey]?.[id];
|
||||
: store.configs[DEFAULT_CONFIG_KEY]?.[id];
|
||||
|
||||
const handleChange = (newValue: any) => {
|
||||
setJsonError(null);
|
||||
if (isExternallyManaged && externalSetValue) {
|
||||
externalSetValue(newValue);
|
||||
} else {
|
||||
store.updateConfig(configKey, id, newValue);
|
||||
store.updateConfig(DEFAULT_CONFIG_KEY, id, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -100,7 +99,7 @@ export function ConfigField({
|
|||
if (isExternallyManaged && externalSetValue) {
|
||||
externalSetValue(jsonString);
|
||||
} else {
|
||||
store.updateConfig(configKey, id, jsonString);
|
||||
store.updateConfig(DEFAULT_CONFIG_KEY, id, jsonString);
|
||||
}
|
||||
setJsonError("Invalid JSON format");
|
||||
}
|
||||
|
|
@ -121,7 +120,7 @@ export function ConfigField({
|
|||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium"
|
||||
className="text-foreground font-mono text-lg font-semibold"
|
||||
>
|
||||
{_.startCase(label)}
|
||||
</Label>
|
||||
|
|
@ -135,9 +134,9 @@ export function ConfigField({
|
|||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-xs whitespace-pre-line text-gray-500">
|
||||
<BasicMarkdownText className="text-xs whitespace-pre-line text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
</BasicMarkdownText>
|
||||
)}
|
||||
|
||||
{type === "text" && (
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export function GithubPageLoading() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">GitHub Repository Access</h1>
|
||||
<div className="h-8 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg bg-white p-6 shadow-md dark:bg-gray-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Your Repositories</h2>
|
||||
<div className="h-10 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="py-4"
|
||||
>
|
||||
<div className="mb-2 h-4 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="mb-2 h-3 w-1/2 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-1/4 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
"use client";
|
||||
import { useGitHubAppProvider } from "@/providers/GitHubApp";
|
||||
import { InstallationPrompt } from "./installation-prompt";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RepositoryListProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RepositoryList({ className = "" }: RepositoryListProps) {
|
||||
const { isInstalled, isLoading, error, repositories, refreshRepositories } =
|
||||
useGitHubAppProvider();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("p-4", className)}>
|
||||
<div className="mb-4 h-6 w-24 animate-pulse rounded bg-gray-200"></div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="mb-4 rounded-md border p-4"
|
||||
>
|
||||
<div className="mb-2 h-5 w-48 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-4 w-64 animate-pulse rounded bg-gray-100"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn("p-4", className)}>
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={refreshRepositories}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isInstalled) {
|
||||
return (
|
||||
<div className={cn("p-4", className)}>
|
||||
<InstallationPrompt />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("p-4", className)}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Your GitHub Repositories</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshRepositories}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{repositories.length === 0 ? (
|
||||
<div className="rounded-md border bg-gray-50 p-4 text-center">
|
||||
<p className="mb-4 text-gray-600">
|
||||
No repositories found. You may need to grant access to repositories
|
||||
in GitHub.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/settings/installations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Manage GitHub App permissions
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{repositories.map((repo) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className="rounded-md border p-4 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
<a
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{repo.full_name}
|
||||
</a>
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{repo.description || "No description"}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5",
|
||||
repo.private
|
||||
? "bg-gray-100"
|
||||
: "bg-green-100 text-green-800",
|
||||
)}
|
||||
>
|
||||
{repo.private ? "Private" : "Public"}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
Default branch: {repo.default_branch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 border-t pt-4 text-sm text-gray-500">
|
||||
<p>
|
||||
Need to add or remove repositories?{" "}
|
||||
<a
|
||||
href="https://github.com/settings/installations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Manage on GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,6 +25,23 @@ import { GitHubLogoutButton } from "../github/github-oauth-button";
|
|||
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
||||
import { TooltipIconButton } from "../ui/tooltip-icon-button";
|
||||
import { InstallationSelector } from "../github/installation-selector";
|
||||
import { Settings } from "lucide-react";
|
||||
import NextLink from "next/link";
|
||||
|
||||
function OpenSettingsButton() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<NextLink href="/settings">
|
||||
<Settings className="size-4" />
|
||||
</NextLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Open Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefaultViewProps {
|
||||
threads: ThreadDisplayInfo[];
|
||||
|
|
@ -68,11 +85,12 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<InstallationSelector />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">ready</span>
|
||||
<div className="bg-muted h-1 w-1 rounded-full"></div>
|
||||
<div className="h-1 w-1 rounded-full bg-green-500 dark:bg-green-600"></div>
|
||||
</div>
|
||||
<InstallationSelector />
|
||||
<OpenSettingsButton />
|
||||
<ThemeToggle />
|
||||
<GitHubLogoutButton />
|
||||
</div>
|
||||
|
|
|
|||
214
apps/web/src/features/settings-page/api-keys.tsx
Normal file
214
apps/web/src/features/settings-page/api-keys.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Eye, EyeOff, Key, Trash2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
isVisible: boolean;
|
||||
lastUsed?: string;
|
||||
}
|
||||
|
||||
interface ApiKeySection {
|
||||
title: string;
|
||||
keys: ApiKey[];
|
||||
}
|
||||
|
||||
export function APIKeysTab() {
|
||||
const [apiKeySections, setApiKeySections] = useState<
|
||||
Record<string, ApiKeySection>
|
||||
>({
|
||||
llms: {
|
||||
title: "LLMs",
|
||||
keys: [
|
||||
{
|
||||
id: "anthropicApiKey",
|
||||
name: "Anthropic",
|
||||
value: "",
|
||||
isVisible: false,
|
||||
},
|
||||
{ id: "openaiApiKey", name: "OpenAI", value: "", isVisible: false },
|
||||
{
|
||||
id: "googleApiKey",
|
||||
name: "Google Gen AI",
|
||||
value: "",
|
||||
isVisible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
infrastructure: {
|
||||
title: "Infrastructure",
|
||||
keys: [
|
||||
{ id: "daytonaApiKey", name: "Daytona", value: "", isVisible: false },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const toggleKeyVisibility = (sectionKey: string, keyId: string) => {
|
||||
setApiKeySections((prev) => ({
|
||||
...prev,
|
||||
[sectionKey]: {
|
||||
...prev[sectionKey],
|
||||
keys: prev[sectionKey].keys.map((key) =>
|
||||
key.id === keyId ? { ...key, isVisible: !key.isVisible } : key,
|
||||
),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const updateApiKey = (sectionKey: string, keyId: string, value: string) => {
|
||||
setApiKeySections((prev) => ({
|
||||
...prev,
|
||||
[sectionKey]: {
|
||||
...prev[sectionKey],
|
||||
keys: prev[sectionKey].keys.map((key) =>
|
||||
key.id === keyId ? { ...key, value } : key,
|
||||
),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteApiKey = (sectionKey: string, keyId: string) => {
|
||||
setApiKeySections((prev) => ({
|
||||
...prev,
|
||||
[sectionKey]: {
|
||||
...prev[sectionKey],
|
||||
keys: prev[sectionKey].keys.map((key) =>
|
||||
key.id === keyId ? { ...key, value: "" } : key,
|
||||
),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(apiKeySections).map(([sectionKey, section]) => (
|
||||
<Card
|
||||
key={sectionKey}
|
||||
className="bg-card border-border shadow-sm"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Key className="h-5 w-5" />
|
||||
{section.title}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage API keys for {section.title.toLowerCase()} services
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{section.keys.map((apiKey) => (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
className="border-border rounded-lg border p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-foreground font-mono font-semibold">
|
||||
{apiKey.name}
|
||||
</h3>
|
||||
{apiKey.value && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
"border-green-200 bg-green-50 text-green-700",
|
||||
"dark:border-green-800 dark:bg-green-900/20 dark:text-green-400",
|
||||
)}
|
||||
>
|
||||
Configured
|
||||
</Badge>
|
||||
)}
|
||||
{apiKey.lastUsed && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Last used {apiKey.lastUsed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`${apiKey.id}-key`}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
API Key
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Input
|
||||
id={`${apiKey.id}-key`}
|
||||
type={apiKey.isVisible ? "text" : "password"}
|
||||
value={apiKey.value}
|
||||
onChange={(e) =>
|
||||
updateApiKey(sectionKey, apiKey.id, e.target.value)
|
||||
}
|
||||
placeholder={`Enter your ${apiKey.name} API key`}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toggleKeyVisibility(sectionKey, apiKey.id)
|
||||
}
|
||||
className="px-2"
|
||||
>
|
||||
{apiKey.isVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{apiKey.value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteApiKey(sectionKey, apiKey.id)}
|
||||
className={cn(
|
||||
"px-2",
|
||||
"text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Your API key is stored securely and encrypted
|
||||
</p>
|
||||
{apiKey.value && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
apps/web/src/features/settings-page/config-manager.tsx
Normal file
241
apps/web/src/features/settings-page/config-manager.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings } from "lucide-react";
|
||||
import { ConfigField } from "@/components/configuration/config-field";
|
||||
import { useConfigStore, DEFAULT_CONFIG_KEY } from "@/hooks/useConfigStore";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ConfigurableFieldUIMetadata } from "@open-swe/shared/configurable-metadata";
|
||||
import { GraphConfigurationMetadata } from "@open-swe/shared/open-swe/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Extract configuration metadata from the GraphConfiguration Zod schema
|
||||
*/
|
||||
function extractConfigurationsFromSchema(
|
||||
configurable: Record<string, any>,
|
||||
): ConfigurableFieldUIMetadata[] {
|
||||
const configurations: ConfigurableFieldUIMetadata[] = [];
|
||||
|
||||
for (const [label, { x_open_swe_ui_config: metadata }] of Object.entries(
|
||||
GraphConfigurationMetadata,
|
||||
)) {
|
||||
if (metadata.type === "hidden") {
|
||||
continue;
|
||||
}
|
||||
configurations.push({
|
||||
label,
|
||||
type: metadata.type,
|
||||
default: configurable[label] || metadata.default,
|
||||
description: metadata.description,
|
||||
placeholder: metadata.placeholder,
|
||||
options: metadata.options,
|
||||
min: metadata.min,
|
||||
max: metadata.max,
|
||||
step: metadata.step,
|
||||
});
|
||||
}
|
||||
|
||||
return configurations;
|
||||
}
|
||||
|
||||
export function ConfigManager() {
|
||||
const { configs, updateConfig, getConfig } = useConfigStore();
|
||||
const [defaultConfig, setDefaultConfig] = useState<
|
||||
ConfigurableFieldUIMetadata[]
|
||||
>([]);
|
||||
const [configurations, setConfigurations] = useState<
|
||||
ConfigurableFieldUIMetadata[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadConfigurations = async () => {
|
||||
// TODO: If we implement a concept of users and start storing config on assistants,
|
||||
// we will need to update this to fetch configs from the assistant first.
|
||||
setLoading(true);
|
||||
setDefaultConfig(extractConfigurationsFromSchema({}));
|
||||
|
||||
if (getConfig(DEFAULT_CONFIG_KEY)) {
|
||||
const actualConfigs = extractConfigurationsFromSchema(
|
||||
getConfig(DEFAULT_CONFIG_KEY),
|
||||
);
|
||||
setConfigurations(actualConfigs);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const actualConfigs = extractConfigurationsFromSchema({});
|
||||
actualConfigs.forEach((c) => {
|
||||
updateConfig(DEFAULT_CONFIG_KEY, c.label, c.default);
|
||||
});
|
||||
setConfigurations(actualConfigs);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigurations();
|
||||
}, []);
|
||||
|
||||
const hasConfiguredValues = configurations.some((config) => {
|
||||
const currentValue = configs[DEFAULT_CONFIG_KEY]?.[config.label];
|
||||
const defaultValue = defaultConfig.find(
|
||||
(c) => c.label === config.label,
|
||||
)?.default;
|
||||
return currentValue !== undefined && currentValue !== defaultValue;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Settings className="h-5 w-5" />
|
||||
Agent Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure agent behavior and model parameters. Will auto-save
|
||||
changes.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasConfiguredValues && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
"border-blue-200 bg-blue-50 text-blue-700",
|
||||
"dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
Customized
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : configurations.length > 0 ? (
|
||||
configurations.map(
|
||||
(config: ConfigurableFieldUIMetadata, index: number) => (
|
||||
<div
|
||||
key={`${config.label}-${index}`}
|
||||
className="border-border rounded-md border-[1px] p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const currentValue =
|
||||
configs[DEFAULT_CONFIG_KEY]?.[config.label];
|
||||
const defaultValue = defaultConfig.find(
|
||||
(c) => c.label === config.label,
|
||||
)?.default;
|
||||
const isModified =
|
||||
currentValue !== undefined &&
|
||||
currentValue !== defaultValue;
|
||||
|
||||
return (
|
||||
isModified && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
"border-orange-200 bg-orange-50 text-orange-700",
|
||||
"dark:border-orange-800 dark:bg-orange-900/20 dark:text-orange-400",
|
||||
)}
|
||||
>
|
||||
Modified
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ConfigField
|
||||
id={config.label}
|
||||
label={config.label}
|
||||
type={
|
||||
config.type === "boolean"
|
||||
? "switch"
|
||||
: (config.type ?? "text")
|
||||
}
|
||||
description={config.description}
|
||||
placeholder={config.placeholder}
|
||||
options={config.options}
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
step={config.step}
|
||||
value={configs[DEFAULT_CONFIG_KEY]?.[config.label]}
|
||||
setValue={(value) => {
|
||||
console.log("setting value!", value);
|
||||
updateConfig(DEFAULT_CONFIG_KEY, config.label, value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{(() => {
|
||||
const currentValue =
|
||||
configs[DEFAULT_CONFIG_KEY]?.[config.label];
|
||||
const defaultValue = defaultConfig.find(
|
||||
(c) => c.label === config.label,
|
||||
)?.default;
|
||||
const isModified =
|
||||
currentValue !== undefined &&
|
||||
currentValue !== defaultValue;
|
||||
|
||||
return (
|
||||
isModified && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updateConfig(
|
||||
DEFAULT_CONFIG_KEY,
|
||||
config.label,
|
||||
defaultValue,
|
||||
)
|
||||
}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<Settings className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
No Configuration Available
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
No configurable parameters found for the current context.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
475
apps/web/src/features/settings-page/github-manager.tsx
Normal file
475
apps/web/src/features/settings-page/github-manager.tsx
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Lock,
|
||||
Globe,
|
||||
ExternalLink,
|
||||
Building2,
|
||||
User,
|
||||
Github,
|
||||
LogIn,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { useGitHubAppProvider } from "@/providers/GitHubApp";
|
||||
import { InstallationSelector } from "@/components/github/installation-selector";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function GitHubManager() {
|
||||
const {
|
||||
isInstalled,
|
||||
isLoading,
|
||||
error,
|
||||
installations,
|
||||
currentInstallation,
|
||||
installationsLoading,
|
||||
installationsError,
|
||||
switchInstallation,
|
||||
refreshInstallations,
|
||||
repositories,
|
||||
repositoriesLoadingMore,
|
||||
refreshRepositories,
|
||||
loadMoreRepositories,
|
||||
repositoriesHasMore,
|
||||
} = useGitHubAppProvider();
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([refreshRepositories(), refreshInstallations()]);
|
||||
};
|
||||
|
||||
const handleInstallApp = () => {
|
||||
window.location.href = "/api/github/installation";
|
||||
};
|
||||
|
||||
const handleManageOnGitHub = () => {
|
||||
const appId = process.env.NEXT_PUBLIC_GITHUB_APP_ID;
|
||||
const fallbackInstallationsUrl =
|
||||
"https://github.com/settings/installations";
|
||||
const applicationUrl = appId
|
||||
? `https://github.com/settings/connections/applications/${appId}`
|
||||
: fallbackInstallationsUrl;
|
||||
window.open(applicationUrl, "_blank");
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
window.location.href = "/api/auth/github/login";
|
||||
};
|
||||
|
||||
const isAuthError = (errorMessage: string | null) => {
|
||||
return (
|
||||
errorMessage?.includes("GitHub access token not found") ||
|
||||
errorMessage?.includes("Please authenticate first") ||
|
||||
errorMessage?.includes("authentication") ||
|
||||
errorMessage?.includes("access token")
|
||||
);
|
||||
};
|
||||
|
||||
if (isAuthError(installationsError) || isAuthError(error)) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Github className="h-6 w-6" />
|
||||
GitHub Authentication Required
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in with GitHub to access your repositories and manage
|
||||
installations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border-border bg-muted/50 rounded-lg border p-6">
|
||||
<div className="mb-6 text-center">
|
||||
<Github className="text-muted-foreground mx-auto mb-4 h-16 w-16" />
|
||||
<h3 className="text-foreground mb-2 text-lg font-semibold">
|
||||
Connect Your GitHub Account
|
||||
</h3>
|
||||
<p className="text-muted-foreground mx-auto max-w-md text-sm">
|
||||
To manage your GitHub repositories and app installations, you
|
||||
need to authenticate with GitHub first. This will allow you to
|
||||
select repositories and work with your code.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Check Authentication Status
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || installationsLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">GitHub Integration</CardTitle>
|
||||
<CardDescription>Loading your GitHub setup...</CardDescription>
|
||||
</div>
|
||||
<div className="bg-muted h-10 w-24 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-muted h-20 animate-pulse rounded"
|
||||
></div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(error && !isAuthError(error)) ||
|
||||
(installationsError && !isAuthError(installationsError))
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive text-xl">
|
||||
GitHub Integration Error
|
||||
</CardTitle>
|
||||
<CardDescription>{error || installationsError}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
variant="default"
|
||||
>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
Re-authenticate
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isInstalled) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">GitHub App Installation</CardTitle>
|
||||
<CardDescription>
|
||||
Install the GitHub App to connect your repositories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border-border bg-muted/50 rounded-lg border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-foreground mb-2 font-semibold">
|
||||
Get Started with GitHub Integration
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
To access your GitHub repositories, you need to install our
|
||||
GitHub App and grant it access to the repositories you want to
|
||||
use.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleInstallApp}
|
||||
className="w-full"
|
||||
>
|
||||
Install GitHub App
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">
|
||||
GitHub{" "}
|
||||
{currentInstallation?.accountType === "Organization"
|
||||
? "Organization"
|
||||
: "User"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your GitHub App installations and switch between
|
||||
organizations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border-border bg-muted/50 flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-foreground mb-1 font-semibold">
|
||||
Current{" "}
|
||||
{currentInstallation?.accountType === "Organization"
|
||||
? "Organization"
|
||||
: "User"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select which GitHub organization or user account to work with
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<InstallationSelector
|
||||
size="default"
|
||||
className="min-w-[250px]"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleInstallApp}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title="Add new organization"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={refreshInstallations}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={installationsLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
installationsLoading && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentInstallation && (
|
||||
<div className="border-border flex items-center gap-4 rounded-lg border p-4">
|
||||
<img
|
||||
src={currentInstallation.avatarUrl}
|
||||
alt={`${currentInstallation.accountName} avatar`}
|
||||
className="h-12 w-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-foreground font-semibold">
|
||||
{currentInstallation.accountName}
|
||||
</h4>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{currentInstallation.accountType === "Organization" ? (
|
||||
<>
|
||||
<Building2 className="mr-1 h-3 w-3" />
|
||||
Organization
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className="mr-1 h-3 w-3" />
|
||||
Personal
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{repositories.length} repositories accessible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Your Repositories</CardTitle>
|
||||
<CardDescription>
|
||||
Repositories you have access to through your GitHub integration
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="font-mono"
|
||||
>
|
||||
{repositories.length} repositories
|
||||
</Badge>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading || repositoriesLoadingMore}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
(isLoading || repositoriesLoadingMore) && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{repositories.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No repositories found. Make sure you've granted access to at
|
||||
least one repository for the selected organization.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{repositories.map((repo, index) => (
|
||||
<div key={repo.id}>
|
||||
<div className="border-border hover:bg-muted/50 flex items-start justify-between rounded-lg border p-4 transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h3 className="text-primary hover:text-primary/80 cursor-pointer font-mono font-semibold">
|
||||
{repo.full_name}
|
||||
</h3>
|
||||
<Badge
|
||||
variant={repo.private ? "secondary" : "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{repo.private ? (
|
||||
<>
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
Private
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe className="mr-1 h-3 w-3" />
|
||||
Public
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<p className="text-muted-foreground mb-3 text-sm">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="font-mono">
|
||||
Default: {repo.default_branch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
onClick={() => window.open(repo.html_url, "_blank")}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{index < repositories.length - 1 && (
|
||||
<Separator className="my-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{repositoriesHasMore && (
|
||||
<div className="pt-4 text-center">
|
||||
<Button
|
||||
onClick={loadMoreRepositories}
|
||||
disabled={repositoriesLoadingMore}
|
||||
variant="outline"
|
||||
>
|
||||
{repositoriesLoadingMore ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Load More Repositories"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">GitHub App Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your GitHub App installation and permissions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border-border bg-muted/50 flex items-center justify-between rounded-lg border p-4">
|
||||
<div>
|
||||
<h3 className="text-foreground mb-1 font-semibold">
|
||||
GitHub App Installation
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You can manage your GitHub App installation, including adding or
|
||||
removing repositories, through GitHub.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleManageOnGitHub}
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Manage on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/features/settings-page/index.tsx
Normal file
103
apps/web/src/features/settings-page/index.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Key, Settings, ArrowLeft } from "lucide-react";
|
||||
import { GitHubManager } from "./github-manager";
|
||||
import { APIKeysTab } from "./api-keys";
|
||||
import { ConfigManager } from "./config-manager";
|
||||
import { GitHubSVG } from "@/components/icons/github";
|
||||
import { GitHubAppProvider } from "@/providers/GitHubApp";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useQueryState("tab", {
|
||||
defaultValue: "github" as "github" | "api-keys" | "configuration",
|
||||
parse: (value: string) => {
|
||||
if (["github", "api-keys", "configuration"].includes(value)) {
|
||||
return value as "github" | "api-keys" | "configuration";
|
||||
}
|
||||
return "github";
|
||||
},
|
||||
serialize: (value) => value,
|
||||
});
|
||||
|
||||
const getTabClassName = (isActive: boolean) =>
|
||||
cn(
|
||||
"relative border-b-2 px-4 py-3 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "border-primary bg-background text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
|
||||
);
|
||||
|
||||
return (
|
||||
<GitHubAppProvider>
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
{/* Header with back button and theme toggle */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/chat")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Chat
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-foreground mb-2 text-3xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your integrations and API configurations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="border-border bg-muted/50 flex rounded-t-lg border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab("github")}
|
||||
className={getTabClassName(activeTab === "github")}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-mono">
|
||||
<GitHubSVG
|
||||
height="16"
|
||||
width="16"
|
||||
/>
|
||||
GitHub
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("api-keys")}
|
||||
className={getTabClassName(activeTab === "api-keys")}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-mono">
|
||||
<Key className="size-4" />
|
||||
API Keys
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("configuration")}
|
||||
className={getTabClassName(activeTab === "configuration")}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-mono">
|
||||
<Settings className="size-4" />
|
||||
Configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border bg-background rounded-b-lg border border-t-0 p-6">
|
||||
{activeTab === "github" && <GitHubManager />}
|
||||
{activeTab === "api-keys" && <APIKeysTab />}
|
||||
{activeTab === "configuration" && <ConfigManager />}
|
||||
</div>
|
||||
</div>
|
||||
</GitHubAppProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -279,7 +279,7 @@ export const GraphConfigurationMetadata: {
|
|||
min: 1,
|
||||
max: 250,
|
||||
description:
|
||||
"Maximum number of context gathering actions during planning",
|
||||
"Maximum number of actions allowed during the context gathering phase. An action consists of a tool call.",
|
||||
},
|
||||
},
|
||||
maxReviewActions: {
|
||||
|
|
@ -288,7 +288,8 @@ export const GraphConfigurationMetadata: {
|
|||
default: 30,
|
||||
min: 1,
|
||||
max: 250,
|
||||
description: "Maximum number of review actions during planning",
|
||||
description:
|
||||
"Maximum number of review actions allowed during the review phase. An action consists of a tool call.",
|
||||
},
|
||||
},
|
||||
programmerModelName: {
|
||||
|
|
@ -296,7 +297,7 @@ export const GraphConfigurationMetadata: {
|
|||
type: "select",
|
||||
default: "anthropic:claude-sonnet-4-0",
|
||||
description:
|
||||
"The model to use for programming/other advanced technical tasks",
|
||||
"The model to use for programming/other advanced technical tasks. This model should be very good at generating code, and have strong context understanding and reasoning capabilities. It will be used for the most complex tasks throughout the agent.",
|
||||
options: MODEL_OPTIONS_NO_THINKING,
|
||||
},
|
||||
},
|
||||
|
|
@ -314,7 +315,8 @@ export const GraphConfigurationMetadata: {
|
|||
x_open_swe_ui_config: {
|
||||
type: "select",
|
||||
default: "anthropic:claude-3-5-haiku-latest",
|
||||
description: "The model to use for routing tasks",
|
||||
description:
|
||||
"The model to use for routing tasks, and other simple generations. This model should be good at tool calling/structured output.",
|
||||
options: MODEL_OPTIONS,
|
||||
},
|
||||
},
|
||||
|
|
@ -333,7 +335,7 @@ export const GraphConfigurationMetadata: {
|
|||
type: "select",
|
||||
default: "anthropic:claude-sonnet-4-0",
|
||||
description:
|
||||
"The model to use for summarizing the conversation history, or extracting key context from large inputs",
|
||||
"The model to use for summarizing the conversation history, or extracting key context from large inputs. This model should have strong context retention/understanding capabilities, and should be good at tool calling/structured output.",
|
||||
options: MODEL_OPTIONS_NO_THINKING,
|
||||
},
|
||||
},
|
||||
|
|
@ -354,7 +356,7 @@ export const GraphConfigurationMetadata: {
|
|||
min: 1,
|
||||
max: 64_000,
|
||||
description:
|
||||
"The maximum number of tokens to generate in an individual generation",
|
||||
"The maximum number of tokens to generate in an individual LLM call. Increasing/decreasing this number _will_ effect how many tokens are generated by the model. It will _not_ simply cut off the generation after the specified number of tokens is reached.",
|
||||
},
|
||||
},
|
||||
[GITHUB_TOKEN_COOKIE]: {
|
||||
|
|
@ -390,9 +392,9 @@ export const GraphConfigurationMetadata: {
|
|||
mcpServers: {
|
||||
x_open_swe_ui_config: {
|
||||
type: "json",
|
||||
default: JSON.stringify(DEFAULT_MCP_SERVERS),
|
||||
default: JSON.stringify(DEFAULT_MCP_SERVERS, null, 2),
|
||||
description:
|
||||
"JSON configuration for custom MCP servers. LangGraph docs server is set by default.",
|
||||
"JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -464,13 +466,8 @@ export const GraphConfiguration = z.object({
|
|||
* The maximum number of tokens to generate in an individual generation.
|
||||
* @default 10_000
|
||||
*/
|
||||
maxTokens: withLangGraph(z.number(), {
|
||||
maxTokens: withLangGraph(z.number().optional(), {
|
||||
metadata: GraphConfigurationMetadata.maxTokens,
|
||||
reducer: {
|
||||
schema: z.number(),
|
||||
fn: (_state, update) => update,
|
||||
},
|
||||
default: () => 10_000,
|
||||
}),
|
||||
/**
|
||||
* The user's GitHub access token. To be used in requests to get information about the user.
|
||||
|
|
@ -514,13 +511,8 @@ export const GraphConfiguration = z.object({
|
|||
* Custom MCP servers configuration as JSON string. Merges with default servers.
|
||||
* @default Default LangGraph docs MCP server
|
||||
*/
|
||||
mcpServers: withLangGraph(z.string(), {
|
||||
mcpServers: withLangGraph(z.string().optional(), {
|
||||
metadata: GraphConfigurationMetadata.mcpServers,
|
||||
default: () => JSON.stringify(DEFAULT_MCP_SERVERS),
|
||||
reducer: {
|
||||
schema: z.string(),
|
||||
fn: (_state, update) => update,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue