feat: support tool level permission control in ui (#2133)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
|
||||
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
|
||||
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
|
||||
import { client as _heyApiClient } from './client.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||
@@ -64,6 +64,17 @@ export const initConfig = <ThrowOnError extends boolean = false>(options?: Optio
|
||||
});
|
||||
};
|
||||
|
||||
export const upsertPermissions = <ThrowOnError extends boolean = false>(options: Options<UpsertPermissionsData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<UpsertPermissionsResponse, unknown, ThrowOnError>({
|
||||
url: '/config/permissions',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const providers = <ThrowOnError extends boolean = false>(options?: Options<ProvidersData, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).get<ProvidersResponse2, unknown, ThrowOnError>({
|
||||
url: '/config/providers',
|
||||
|
||||
@@ -235,12 +235,24 @@ export type ToolInfo = {
|
||||
permission?: PermissionLevel | null;
|
||||
};
|
||||
|
||||
export type ToolPermission = {
|
||||
permission: PermissionLevel;
|
||||
/**
|
||||
* Unique identifier and name of the tool, format <extension_name>__<tool_name>
|
||||
*/
|
||||
tool_name: string;
|
||||
};
|
||||
|
||||
export type UpsertConfigQuery = {
|
||||
is_secret: boolean;
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type UpsertPermissionsQuery = {
|
||||
tool_permissions: Array<ToolPermission>;
|
||||
};
|
||||
|
||||
export type GetToolsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
@@ -272,7 +284,7 @@ export type GetToolsResponses = {
|
||||
/**
|
||||
* Tools retrieved successfully
|
||||
*/
|
||||
200: Array<Tool>;
|
||||
200: Array<ToolInfo>;
|
||||
};
|
||||
|
||||
export type GetToolsResponse = GetToolsResponses[keyof GetToolsResponses];
|
||||
@@ -399,6 +411,29 @@ export type InitConfigResponses = {
|
||||
|
||||
export type InitConfigResponse = InitConfigResponses[keyof InitConfigResponses];
|
||||
|
||||
export type UpsertPermissionsData = {
|
||||
body: UpsertPermissionsQuery;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/permissions';
|
||||
};
|
||||
|
||||
export type UpsertPermissionsErrors = {
|
||||
/**
|
||||
* Invalid request
|
||||
*/
|
||||
400: unknown;
|
||||
};
|
||||
|
||||
export type UpsertPermissionsResponses = {
|
||||
/**
|
||||
* Permission update completed
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type UpsertPermissionsResponse = UpsertPermissionsResponses[keyof UpsertPermissionsResponses];
|
||||
|
||||
export type ProvidersData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -1,22 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { ConfirmToolRequest } from '../utils/toolConfirm';
|
||||
import { snakeToTitleCase } from '../utils';
|
||||
import PermissionModal from './settings_v2/permission/PermissionModal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
const ALWAYS_ALLOW = 'always_allow';
|
||||
const ALLOW_ONCE = 'allow_once';
|
||||
const DENY = 'deny';
|
||||
|
||||
interface ToolConfirmationProps {
|
||||
isCancelledMessage: boolean;
|
||||
isClicked: boolean;
|
||||
toolConfirmationId: string;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export default function ToolConfirmation({
|
||||
isCancelledMessage,
|
||||
isClicked,
|
||||
toolConfirmationId,
|
||||
toolName,
|
||||
}) {
|
||||
}: ToolConfirmationProps) {
|
||||
const [clicked, setClicked] = useState(isClicked);
|
||||
const [status, setStatus] = useState('unknown');
|
||||
const [actionDisplay, setActionDisplay] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleButtonClick = (confirmed) => {
|
||||
const handleButtonClick = (action: string) => {
|
||||
setClicked(true);
|
||||
setStatus(confirmed ? 'approved' : 'denied');
|
||||
ConfirmToolRequest(toolConfirmationId, confirmed);
|
||||
setStatus(action);
|
||||
if (action === ALWAYS_ALLOW) {
|
||||
setActionDisplay('always allowed');
|
||||
} else if (action === ALLOW_ONCE) {
|
||||
setActionDisplay('allowed once');
|
||||
} else {
|
||||
setActionDisplay('denied');
|
||||
}
|
||||
ConfirmToolRequest(toolConfirmationId, action);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
function getExtensionName(toolName: string): string {
|
||||
const parts = toolName.split('__');
|
||||
return parts.length > 1 ? parts[0] : '';
|
||||
}
|
||||
|
||||
return isCancelledMessage ? (
|
||||
<div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 text-textStandard">
|
||||
Tool call confirmation is cancelled.
|
||||
@@ -27,9 +58,9 @@ export default function ToolConfirmation({
|
||||
Goose would like to call the above tool. Allow?
|
||||
</div>
|
||||
{clicked ? (
|
||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 flex gap-4 mt-1">
|
||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{status === 'approved' && (
|
||||
{status === 'always_allow' && (
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -41,7 +72,19 @@ export default function ToolConfirmation({
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{status === 'denied' && (
|
||||
{status === 'allow_once' && (
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{status === 'deny' && (
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -56,30 +99,48 @@ export default function ToolConfirmation({
|
||||
<span className="ml-2 text-textStandard">
|
||||
{isClicked
|
||||
? 'Tool confirmation is not available'
|
||||
: `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${status}`}
|
||||
: `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${actionDisplay}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center cursor-pointer" onClick={() => setIsModalOpen(true)}>
|
||||
<span className="mr-1 text-textStandard">Change</span>
|
||||
<ChevronRight className="w-4 h-4 ml-1 text-iconStandard" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 flex gap-4 mt-1">
|
||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center">
|
||||
<button
|
||||
className={
|
||||
'bg-black text-white dark:bg-white dark:text-black rounded-full px-6 py-2 transition'
|
||||
}
|
||||
onClick={() => handleButtonClick(true)}
|
||||
onClick={() => handleButtonClick(ALWAYS_ALLOW)}
|
||||
>
|
||||
Allow tool
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
'bg-bgProminent text-white dark:text-white rounded-full px-6 py-2 transition'
|
||||
}
|
||||
onClick={() => handleButtonClick(ALLOW_ONCE)}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
'bg-white text-black dark:bg-black dark:text-white border border-gray-300 dark:border-gray-700 rounded-full px-6 py-2 transition'
|
||||
}
|
||||
onClick={() => handleButtonClick(false)}
|
||||
onClick={() => handleButtonClick(DENY)}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal for updating tool permission */}
|
||||
{isModalOpen && (
|
||||
<PermissionModal onClose={handleModalClose} extensionName={getExtensionName(toolName)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { ChevronDownIcon, SlidersHorizontal } from 'lucide-react';
|
||||
import { getTools, PermissionLevel, ToolInfo, upsertPermissions } from '../../../api';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
function getFirstSentence(text: string): string {
|
||||
const match = text.match(/^([^.?!]+[.?!])/);
|
||||
return match ? match[0] : '';
|
||||
}
|
||||
|
||||
interface PermissionModalProps {
|
||||
extensionName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PermissionModal({ extensionName, onClose }: PermissionModalProps) {
|
||||
const permissionOptions = [
|
||||
{ value: 'always_allow', label: 'Always allow' },
|
||||
{ value: 'ask_before', label: 'Ask before' },
|
||||
{ value: 'never_allow', label: 'Never allow' },
|
||||
] as { value: PermissionLevel; label: string }[];
|
||||
|
||||
const [tools, setTools] = useState<ToolInfo[]>([]);
|
||||
const [updatedPermissions, setUpdatedPermissions] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTools = async () => {
|
||||
try {
|
||||
const response = await getTools({ query: { extension_name: extensionName } });
|
||||
if (response.error) {
|
||||
console.error('Failed to get tools');
|
||||
} else {
|
||||
setTools(response.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching tools:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTools();
|
||||
}, [extensionName]);
|
||||
|
||||
const handleSettingChange = (toolName: string, newPermission: PermissionLevel) => {
|
||||
setUpdatedPermissions((prev) => ({
|
||||
...prev,
|
||||
[toolName]: newPermission,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
tool_permissions: Object.entries(updatedPermissions).map(([toolName, permission]) => ({
|
||||
tool_name: toolName,
|
||||
permission: permission as PermissionLevel,
|
||||
})),
|
||||
};
|
||||
|
||||
if (payload.tool_permissions.length === 0) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await upsertPermissions({
|
||||
body: payload,
|
||||
});
|
||||
if (response.error) {
|
||||
console.error('Failed to save permissions:', response.error);
|
||||
} else {
|
||||
console.log('Permissions updated successfully');
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving permissions:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const footerContent = (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-md"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-md font-regular"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/30 dark:bg-white/20 transition-colors animate-[fadein_200ms_ease-in_forwards]" />
|
||||
<Dialog.Content className="fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] w-[500px] max-w-[90vw] bg-bgApp rounded-xl shadow-xl max-h-[90vh] flex flex-col">
|
||||
<div className="p-6">
|
||||
<Dialog.Title className="DialogTitle flex justify-start items-center mb-6">
|
||||
<SlidersHorizontal className="text-iconStandard" size={24} />
|
||||
<p className="ml-2 text-2xl font-semibold text-textStandard">{extensionName}</p>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="DialogDescription"></Dialog.Description>
|
||||
|
||||
<div className="flex justify-center items-center h-full">
|
||||
{tools.length === 0 ? (
|
||||
<div className="flex items-center justify-center">
|
||||
{/* Loading spinner */}
|
||||
<svg
|
||||
className="animate-spin h-8 w-8 text-grey-50 dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v8H4z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="mb-4 flex items-center justify-between grid grid-cols-12"
|
||||
>
|
||||
<div className="flex flex-col col-span-8">
|
||||
<label className="block text-sm font-medium text-textStandard">
|
||||
{tool.name}
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{getFirstSentence(tool.description)}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger className="flex col-span-4 items-center justify-center bg-bgSubtle text-textStandard rounded-full px-3 py-2">
|
||||
<span>
|
||||
{permissionOptions.find(
|
||||
(option) =>
|
||||
option.value === (updatedPermissions[tool.name] || tool.permission)
|
||||
)?.label || 'Ask Before'}
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="bg-white dark:bg-bgProminent rounded-lg shadow-md">
|
||||
{permissionOptions.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.value}
|
||||
className="px-8 py-2 rounded-lg hover:cursor-pointer hover:bg-bgSubtle text-textStandard"
|
||||
onSelect={() =>
|
||||
handleSettingChange(tool.name, option.value as PermissionLevel)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footerContent && (
|
||||
<div className="border-t border-borderSubtle bg-bgApp w-full rounded-b-xl overflow-hidden">
|
||||
{footerContent}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getApiUrl, getSecretKey } from '../config';
|
||||
|
||||
export async function ConfirmToolRequest(requesyId: string, confirmed: boolean) {
|
||||
export async function ConfirmToolRequest(requesyId: string, action: string) {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/confirm'), {
|
||||
method: 'POST',
|
||||
@@ -10,7 +10,7 @@ export async function ConfirmToolRequest(requesyId: string, confirmed: boolean)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: requesyId,
|
||||
confirmed,
|
||||
action,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user