rowboat/apps/rowboat/app/projects/[projectId]/config/app.tsx
akhisud3195 9157f87dc7 Add hosted tools + revamp tools UX
Replace db storage with api calls to klavis for list servers and add filters to hosted tool views

Add logging and simplify oauth

Refactor klavis API calls to go via a proxy

Add projectAuthCheck() to klavis actions

Fix build error in stream-response route.ts

PARTIAL: Revamp tools modal

PARTIAL: Manage mcp servers at project level document

PARTIAL: Fetch tools from MCP servers upon toggle ON

PARTIAL: Propogate hosted MCP tools to entity_list in build view

Show tool toggle banner

Add sync explicitly to prevent long page load time for MCP server's tools

PARTIAL: Fix auth flow DB writes

PARTIAL: Add tools with isready flag for auth-related server handling

PARTIAL: Bring back sync tools CTA

Fix tool selection issues

PARTIAL: Fix sync issues with enriched and available tools and log unenriched tool names

Remove buggy log statement

Refactor common components and refactor HostedServer

PARTIAL: Add custom servers and standardize the UI

PARTIAL: Add modal and small UI improvements to custom servers page

Show clubbed MCP tools in entity_list

Add tool filters in tools section of entity_list

Revert text in add tool CTA

Make entity_list sections collapsed when one is expanded

Merge project level tools to workflow level tools when sending requests to agent service

Restore original panel-common variants

Reduce agentic workflow request by removing tools from mcp servers

Merge project level tools to workflow level tools when sending requests to copilot service

Fix padding issues in entity_list headers

Update package-lock.json

Revert package* files to devg

Revert tsconfig to dev

PARTIAL: Change tabs and switch to heroui pending switch issues

Fix switch issues with heroui

Pass projectTools via workflow/app to entity_list and do not write to DB

Fix issue with tool_config rendering and @ mentions for project tools

Include @ mentioned project tools in agent request

Update copilot usage of project tools

Read mcp server url directly from tool config in agents service

Make entity_list panels resizable

Update resize handlers across the board

Change Hosted MCP servers ---> Tools Library

Remove tools filter

Remove filter tabs in hosted tools

Move tools selected / tools available labels below card titles

Remove tools from config / settings page

Bring back old mcp servers handling in agents service for backward compatibility as fallback

Remove web_search from project template

Add icons for agents, tools and prompts in entity_list

Enable agents reordering in entity_list

Fix build errors

Make entity_list icons more transparent

Add logos for hosted tools and fix importsg

Fix server card component sizes and overflow

Add error handling in hosted servers pageg

Add node_modules to gitignore

remove root package json

add project auth checks

revert to project mcpServers being optional

refactor tool merging and conversion

revert stream route change

Move authURL klavis logic to klavis_actions

Fix tool enrichment for post-auth tools and make logging less verbose

Expand tool schema to include comprehensive json schema fields

Add enabled and ready filters to hosted tools

Add needs auth warning above auth button

Update tools icon

Add github and google client ids to docker-compose

Clean up MCP servers upon project deletion

Remove klavis ai label

Improve server loading on and off UX

Fix bug that was not enriching un-auth servers

Add tool testing capabilities

Fix un-blurred strip in tool testing modal view

Disable server card CTAs during toggling on or off transition

Add beta tag to tools

Add tool and server counts

Truncate long tool descriptions

Add separators between filters in servers view

Support multiple format types in tool testing fields

Fix menu position issue for @ mentions
2025-06-03 17:34:16 +05:30

592 lines
No EOL
20 KiB
TypeScript

'use client';
import { Metadata } from "next";
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@heroui/react";
import { ReactNode, useEffect, useState } from "react";
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
import { CopyButton } from "../../../../components/common/copy-button";
import { EditableField } from "../../../lib/components/editable-field";
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react";
import { WithStringId } from "../../../lib/types/types";
import { ApiKey } from "../../../lib/types/project_types";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { Label } from "../../../lib/components/label";
import { FormSection } from "../../../lib/components/form-section";
import { Panel } from "@/components/common/panel-common";
import { ProjectSection } from './components/project';
export const metadata: Metadata = {
title: "Project config",
};
export function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return <div className="w-full flex flex-col gap-4 border border-border p-4 rounded-md">
<h2 className="font-semibold pb-2 border-b border-border">{title}</h2>
{children}
</div>;
}
export function SectionRow({
children,
}: {
children: ReactNode;
}) {
return <div className="flex flex-col gap-2">{children}</div>;
}
export function LeftLabel({
label,
}: {
label: string;
}) {
return <Label label={label} />;
}
export function RightContent({
children,
}: {
children: React.ReactNode;
}) {
return <div>{children}</div>;
}
export function BasicSettingsSection({
projectId,
}: {
projectId: string;
}) {
const [loading, setLoading] = useState(false);
const [projectName, setProjectName] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setProjectName(project?.name);
setLoading(false);
});
}, [projectId]);
async function updateName(name: string) {
setLoading(true);
await updateProjectName(projectId, name);
setProjectName(name);
setLoading(false);
}
return <Section title="Basic settings">
<FormSection label="Project name">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={projectName || ''}
onChange={updateName}
className="w-full"
/>}
</FormSection>
<Divider />
<FormSection label="Project ID">
<div className="flex flex-row gap-2 items-center">
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
<CopyButton
onCopy={() => {
navigator.clipboard.writeText(projectId);
}}
label="Copy"
successLabel="Copied"
/>
</div>
</FormSection>
</Section>;
}
export function ApiKeysSection({
projectId,
}: {
projectId: string;
}) {
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{
type: 'success' | 'error' | 'info';
text: string;
} | null>(null);
useEffect(() => {
const loadKeys = async () => {
const keys = await listApiKeys(projectId);
setKeys(keys);
setLoading(false);
};
loadKeys();
}, [projectId]);
const handleCreateKey = async () => {
setLoading(true);
setMessage(null);
try {
const key = await createApiKey(projectId);
setLoading(false);
setMessage({
type: 'success',
text: 'API key created successfully',
});
setKeys([...keys, key]);
setTimeout(() => {
setMessage(null);
}, 2000);
} catch (error) {
setLoading(false);
setMessage({
type: 'error',
text: error instanceof Error ? error.message : "Failed to create API key",
});
}
};
const handleDeleteKey = async (id: string) => {
if (!window.confirm("Are you sure you want to delete this API key? This action cannot be undone.")) {
return;
}
try {
setLoading(true);
setMessage(null);
await deleteApiKey(projectId, id);
setKeys(keys.filter((k) => k._id !== id));
setLoading(false);
setMessage({
type: 'info',
text: 'API key deleted successfully',
});
setTimeout(() => {
setMessage(null);
}, 2000);
} catch (error) {
setLoading(false);
setMessage({
type: 'error',
text: error instanceof Error ? error.message : "Failed to delete API key",
});
}
};
return <Section title="API keys">
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
API keys are used to authenticate requests to the Rowboat API.
</p>
<Button
onPress={handleCreateKey}
size="sm"
startContent={<Plus className="h-4 w-4" />}
variant="flat"
isDisabled={loading}
>
Create API key
</Button>
</div>
<Divider />
{loading && <Spinner size="sm" />}
{!loading && <div className="border border-border rounded-lg text-sm">
<div className="flex items-center border-b border-border p-4">
<div className="flex-[3] font-normal">API Key</div>
<div className="flex-1 font-normal">Created</div>
<div className="flex-1 font-normal">Last Used</div>
<div className="w-10"></div>
</div>
{message?.type === 'success' && <div className="flex flex-col p-2">
<div className="text-sm bg-green-50 text-green-500 p-2 rounded-md">{message.text}</div>
</div>}
{message?.type === 'error' && <div className="flex flex-col p-2">
<div className="text-sm bg-red-50 text-red-500 p-2 rounded-md">{message.text}</div>
</div>}
{message?.type === 'info' && <div className="flex flex-col p-2">
<div className="text-sm bg-yellow-50 text-yellow-500 p-2 rounded-md">{message.text}</div>
</div>}
<div className="flex flex-col">
{keys.map((key) => (
<div key={key._id} className="flex items-start border-b border-border last:border-b-0 p-4">
<div className="flex-[3] p-2">
<ApiKeyDisplay apiKey={key.key} />
</div>
<div className="flex-1 p-2">
<RelativeTime date={new Date(key.createdAt)} />
</div>
<div className="flex-1 p-2">
{key.lastUsedAt ? <RelativeTime date={new Date(key.lastUsedAt)} /> : 'Never'}
</div>
<div className="w-10 p-2">
<Dropdown>
<DropdownTrigger>
<button className="text-muted-foreground hover:text-foreground">
<MoreVertical className="h-4 w-4" />
</button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key='delete'
className="text-destructive"
onPress={() => handleDeleteKey(key._id)}
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
))}
{keys.length === 0 && (
<div className="p-4 text-center text-muted-foreground">
No API keys created yet
</div>
)}
</div>
</div>}
</div>
</Section>;
}
export function SecretSection({
projectId,
}: {
projectId: string;
}) {
const [loading, setLoading] = useState(false);
const [hidden, setHidden] = useState(true);
const [secret, setSecret] = useState<string | null>(null);
const formattedSecret = hidden ? `${secret?.slice(0, 2)}${'•'.repeat(5)}${secret?.slice(-2)}` : secret;
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setSecret(project.secret);
setLoading(false);
});
}, [projectId]);
const handleRotateSecret = async () => {
if (!confirm("Are you sure you want to rotate the secret? All existing signatures will become invalid.")) {
return;
}
setLoading(true);
try {
const newSecret = await rotateSecret(projectId);
setSecret(newSecret);
} catch (error) {
console.error('Failed to rotate secret:', error);
} finally {
setLoading(false);
}
};
return <Section title="Secret">
<p className="text-sm">
The project secret is used for signing tool-call requests sent to your webhook
</p>
<Divider />
<SectionRow>
<LeftLabel label="Project secret" />
<RightContent>
<div className="flex flex-row gap-2 items-center">
{loading && <Spinner size="sm" />}
{!loading && secret && <div className="flex flex-row gap-2 items-center">
<div className="text-gray-600 text-sm font-mono break-all">
{formattedSecret}
</div>
<button
onClick={() => setHidden(!hidden)}
className="text-gray-300 hover:text-gray-700 flex items-center gap-1 group"
>
{hidden ? <EyeIcon size={16} /> : <EyeOffIcon size={16} />}
</button>
<CopyButton
onCopy={() => {
navigator.clipboard.writeText(secret);
}}
label="Copy"
successLabel="Copied"
/>
<Button
size="sm"
variant="flat"
color="warning"
onPress={handleRotateSecret}
isDisabled={loading}
>
Rotate
</Button>
</div>}
</div>
</RightContent>
</SectionRow>
</Section>;
}
export function WebhookUrlSection({
projectId,
}: {
projectId: string;
}) {
const [loading, setLoading] = useState(false);
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setWebhookUrl(project.webhookUrl || null);
setLoading(false);
});
}, [projectId]);
async function update(url: string) {
setLoading(true);
await updateWebhookUrl(projectId, url);
setWebhookUrl(url);
setLoading(false);
}
function validate(url: string) {
try {
new URL(url);
return { valid: true };
} catch {
return { valid: false, errorMessage: 'Please enter a valid URL' };
}
}
return <Section title="Webhook URL">
<p className="text-sm">
In workflow editor, tool calls will be posted to this URL, unless they are mocked.
</p>
<Divider />
<FormSection label="Webhook URL">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={webhookUrl || ''}
onChange={update}
validate={validate}
className="w-full"
/>}
</FormSection>
</Section>;
}
export function ChatWidgetSection({
projectId,
chatWidgetHost,
}: {
projectId: string;
chatWidgetHost: string;
}) {
const [loading, setLoading] = useState(false);
const [chatClientId, setChatClientId] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setChatClientId(project.chatClientId);
setLoading(false);
});
}, [projectId]);
const code = `<!-- RowBoat Chat Widget -->
<script>
window.ROWBOAT_CONFIG = {
clientId: '${chatClientId}'
};
(function(d) {
var s = d.createElement('script');
s.src = '${chatWidgetHost}/api/bootstrap.js';
s.async = true;
d.getElementsByTagName('head')[0].appendChild(s);
})(document);
</script>`;
return <Section title="Chat widget">
<p className="text-sm">
To use the chat widget, copy and paste this code snippet just before the closing &lt;/body&gt; tag of your website:
</p>
{loading && <Spinner size="sm" />}
{!loading && <Textarea
variant="bordered"
size="sm"
defaultValue={code}
className="max-w-full cursor-pointer font-mono"
readOnly
endContent={<CopyButton
onCopy={() => {
navigator.clipboard.writeText(code);
}}
label="Copy"
successLabel="Copied"
/>}
/>}
</Section>;
}
export function DeleteProjectSection({
projectId,
}: {
projectId: string;
}) {
const [loading, setLoading] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const [projectName, setProjectName] = useState("");
const [projectNameInput, setProjectNameInput] = useState("");
const [confirmationInput, setConfirmationInput] = useState("");
const isValid = projectNameInput === projectName && confirmationInput === "delete project";
useEffect(() => {
setLoading(true);
getProjectConfig(projectId).then((project) => {
setProjectName(project.name);
setLoading(false);
});
}, [projectId]);
const handleDelete = async () => {
if (!isValid) return;
setLoading(true);
await deleteProject(projectId);
setLoading(false);
};
return (
<Section title="Delete project">
{loading && <Spinner size="sm" />}
{!loading && <div className="flex flex-col gap-4">
<p className="text-sm">
Deleting a project will permanently remove all associated data, including workflows, sources, and API keys.
This action cannot be undone.
</p>
<div>
<Button
color="danger"
size="sm"
onPress={onOpen}
isDisabled={loading}
isLoading={loading}
>
Delete project
</Button>
</div>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader>Delete Project</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<p>
This action cannot be undone. Please type in the following to confirm:
</p>
<Input
label="Project name"
placeholder={projectName}
value={projectNameInput}
onChange={(e) => setProjectNameInput(e.target.value)}
/>
<Input
label='Type "delete project" to confirm'
placeholder="delete project"
value={confirmationInput}
onChange={(e) => setConfirmationInput(e.target.value)}
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button
color="danger"
onPress={handleDelete}
isDisabled={!isValid}
>
Delete Project
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>}
</Section>
);
}
function ApiKeyDisplay({ apiKey }: { apiKey: string }) {
const [isVisible, setIsVisible] = useState(false);
const formattedKey = isVisible ? apiKey : `${apiKey.slice(0, 2)}${'•'.repeat(5)}${apiKey.slice(-2)}`;
return (
<div className="flex flex-col gap-1">
<div className="text-sm font-mono break-all">{formattedKey}</div>
<div className="flex flex-row gap-2 items-center">
<button
onClick={() => setIsVisible(!isVisible)}
className="text-gray-300 hover:text-gray-700"
>
{isVisible ? (
<EyeOffIcon className="w-4 h-4" />
) : (
<EyeIcon className="w-4 h-4" />
)}
</button>
<CopyButton
onCopy={() => {
navigator.clipboard.writeText(apiKey);
}}
label="Copy"
successLabel="Copied"
/>
</div>
</div>
);
}
export function ConfigApp({
projectId,
useChatWidget,
chatWidgetHost,
}: {
projectId: string;
useChatWidget: boolean;
chatWidgetHost: string;
}) {
return (
<div className="h-full overflow-auto p-6">
<Panel
variant="projects"
title={
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
<Settings className="w-4 h-4" />
<span>Project Settings</span>
</div>
}
>
<div className="space-y-6">
<ProjectSection
projectId={projectId}
useChatWidget={useChatWidget}
chatWidgetHost={chatWidgetHost}
/>
</div>
</Panel>
</div>
);
}
// Add default export
export default ConfigApp;