Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add credential list to threads page sidebar #1518

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions ui/admin/app/components/chat/shared/thread-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { toast } from "sonner";
import useSWR from "swr";

import { CredentialNamespace } from "~/lib/model/credentials";
import { KnowledgeFileNamespace } from "~/lib/model/knowledge";
import { UpdateThread } from "~/lib/model/threads";
import { AgentService } from "~/lib/service/api/agentService";
import { CredentialApiService } from "~/lib/service/api/credentialApiService";
import { KnowledgeFileService } from "~/lib/service/api/knowledgeFileApiService";
import { ThreadsService } from "~/lib/service/api/threadsService";

Expand Down Expand Up @@ -68,3 +70,34 @@ export function useThreadAgents(threadId?: Nullish<string>) {
AgentService.getAgentById(agentId)
);
}

export function useThreadCredentials(threadId: Nullish<string>) {
const getCredentials = useSWR(
CredentialApiService.getCredentials.key(
CredentialNamespace.Threads,
threadId
),
({ namespace, entityId }) =>
CredentialApiService.getCredentials(namespace, entityId)
);

const handleDeleteCredential = async (credentialName: string) => {
if (!threadId) return;

return await CredentialApiService.deleteCredential(
CredentialNamespace.Threads,
threadId,
credentialName
);
};

const deleteCredential = useAsync(handleDeleteCredential, {
onSuccess: (credentialId) => {
getCredentials.mutate((creds) =>
creds?.filter((c) => c.name !== credentialId)
);
},
});

return { getCredentials, deleteCredential };
}
240 changes: 152 additions & 88 deletions ui/admin/app/components/thread/ThreadMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
ExternalLink,
FileIcon,
FilesIcon,
KeyIcon,
LucideIcon,
RotateCwIcon,
TrashIcon,
} from "lucide-react";
import { $path } from "safe-routes";

Expand All @@ -16,9 +19,11 @@ import { ThreadsService } from "~/lib/service/api/threadsService";
import { cn } from "~/lib/utils";

import {
useThreadCredentials,
useThreadFiles,
useThreadKnowledge,
} from "~/components/chat/shared/thread-helpers";
import { ConfirmationDialog } from "~/components/composed/ConfirmationDialog";
import { Truncate } from "~/components/composed/typography";
import {
Accordion,
Expand All @@ -36,6 +41,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { useConfirmationDialog } from "~/hooks/component-helpers/useConfirmationDialog";

interface ThreadMetaProps {
entity: Agent | Workflow;
Expand All @@ -57,6 +63,11 @@ export function ThreadMeta({ entity, thread, className }: ThreadMetaProps) {
const getKnowledge = useThreadKnowledge(thread.id);
const { data: knowledge = [] } = getKnowledge;

const { getCredentials, deleteCredential } = useThreadCredentials(thread.id);
const { data: credentials = [] } = getCredentials;

const { dialogProps, interceptAsync } = useConfirmationDialog();

return (
<Card className={cn("bg-0 h-full overflow-hidden", className)}>
<CardContent className="space-y-4 pt-6">
Expand Down Expand Up @@ -128,97 +139,150 @@ export function ThreadMeta({ entity, thread, className }: ThreadMetaProps) {
</table>
</div>

<Accordion type="multiple" className="mx-2" defaultValue={["files"]}>
{files.length > 0 && (
<AccordionItem value="files">
<AccordionTrigger>
<div className="flex w-full items-center justify-between">
<span className="flex items-center">
<FilesIcon className="mr-2 h-4 w-4" />
Files
</span>

<Button
variant="ghost"
size="icon-sm"
loading={getFiles.isValidating}
onClick={(e) => {
e.stopPropagation();
getFiles.mutate();
}}
>
<RotateCwIcon />
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="mx-4">
<ul className="space-y-2">
{files.map((file) => (
<ClickableDiv
key={file.name}
onClick={() =>
ThreadsService.downloadFile(thread.id, file.name)
}
>
<li key={file.name} className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm">
<DownloadIcon />
</Button>
</TooltipTrigger>

<TooltipContent>Download</TooltipContent>
</Tooltip>
<Truncate
className="w-fit flex-1"
tooltipContentProps={{ align: "start" }}
>
{file.name}
</Truncate>
</li>
</ClickableDiv>
))}
</ul>
</AccordionContent>
</AccordionItem>
)}
{knowledge.length > 0 && (
<AccordionItem value="knowledge">
<AccordionTrigger>
<div className="flex w-full items-center justify-between">
<span className="flex items-center">
<FilesIcon className="mr-2 h-4 w-4" />
Knowledge Files
</span>

<Button
variant="ghost"
size="icon-sm"
loading={getKnowledge.isValidating}
onClick={(e) => {
e.stopPropagation();
getKnowledge.mutate();
}}
<Accordion type="multiple" className="mx-2">
<ThreadMetaAccordionItem
value="files"
icon={FilesIcon}
title="Files"
isLoading={getFiles.isValidating}
onRefresh={() => getFiles.mutate()}
items={files}
renderItem={(file) => (
<ClickableDiv
key={file.name}
onClick={() =>
ThreadsService.downloadFile(thread.id, file.name)
}
>
<li className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm">
<DownloadIcon />
</Button>
</TooltipTrigger>

<TooltipContent>Download</TooltipContent>
</Tooltip>
<Truncate
className="w-fit flex-1"
tooltipContentProps={{ align: "start" }}
>
<RotateCwIcon />
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="mx-4">
<ul className="space-y-2">
{knowledge.map((file) => (
<li key={file.id} className="flex items-center">
<FileIcon className="mr-2 h-4 w-4" />
<span>{file.fileName}</span>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
)}
{file.name}
</Truncate>
</li>
</ClickableDiv>
)}
/>

<ThreadMetaAccordionItem
value="knowledge"
icon={FilesIcon}
title="Knowledge Files"
isLoading={getKnowledge.isValidating}
onRefresh={() => getKnowledge.mutate()}
items={knowledge}
renderItem={(file) => (
<li key={file.id} className="flex items-center">
<FileIcon className="mr-2 h-4 w-4" />
<p>{file.fileName}</p>
</li>
)}
/>

<ThreadMetaAccordionItem
value="credentials"
icon={KeyIcon}
title="Credentials"
isLoading={getCredentials.isValidating}
onRefresh={() => getCredentials.mutate()}
items={credentials}
renderItem={(credential) => (
<li
key={credential.name}
className="flex items-center justify-between"
>
<p>{credential.name}</p>

<Button
size="icon-sm"
variant="ghost"
onClick={() =>
interceptAsync(() =>
deleteCredential.executeAsync(credential.name)
)
}
>
<TrashIcon />
</Button>
</li>
)}
/>
</Accordion>

<ConfirmationDialog
{...dialogProps}
title="Delete Credential?"
description="You will need to re-authenticate to use any tools that require this credential."
confirmProps={{
variant: "destructive",
loading: deleteCredential.isLoading,
disabled: deleteCredential.isLoading,
}}
/>
</CardContent>
</Card>
);
}

type ThreadMetaAccordionItemProps<T> = {
value: string;
icon: LucideIcon;
title: string;
isLoading?: boolean;
onRefresh?: (e: React.MouseEvent) => void;
items: T[];
renderItem: (item: T) => React.ReactNode;
emptyMessage?: string;
};

function ThreadMetaAccordionItem<T>(props: ThreadMetaAccordionItemProps<T>) {
const Icon = props.icon;
return (
<AccordionItem value={props.value}>
<AccordionTrigger>
<div className="flex w-full items-center justify-between">
<span className="flex items-center">
<Icon className="mr-2 h-4 w-4" />
{props.title}
</span>

{props.onRefresh && (
<Button
variant="ghost"
size="icon-sm"
loading={props.isLoading}
onClick={(e) => {
e.stopPropagation();
props.onRefresh?.(e);
}}
>
<RotateCwIcon />
</Button>
)}
</div>
</AccordionTrigger>
<AccordionContent className="mx-4">
<ul className="space-y-2">
{props.items.length ? (
props.items.map((item) => props.renderItem(item))
) : (
<li className="flex items-center">
<p>{props.emptyMessage || `No ${props.title.toLowerCase()}`}</p>
</li>
)}
</ul>
</AccordionContent>
</AccordionItem>
);
}
33 changes: 2 additions & 31 deletions ui/admin/app/lib/routers/apiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,34 +35,7 @@ const buildUrl = (path: string, params?: object) => {

export const ApiRoutes = {
assistants: {
base: () => buildUrl("/assistants"),
getAssistants: () => buildUrl("/assistants"),
getCredentials: (assistantId: string) =>
buildUrl(`/assistants/${assistantId}/credentials`),
deleteCredential: (assistantId: string, credentialId: string) =>
buildUrl(`/assistants/${assistantId}/credentials/${credentialId}`),
getEvents: (assistantId: string) =>
buildUrl(`/assistants/${assistantId}/events`),
invoke: (assistantId: string) =>
buildUrl(`/assistants/${assistantId}/invoke`),
getTools: (assistantId: string) =>
buildUrl(`/assistants/${assistantId}/tools`),
deleteTool: (assistantId: string, toolId: string) =>
buildUrl(`/assistants/${assistantId}/tools/${toolId}`),
getFiles: (assistantId: string) =>
buildUrl(`/assistants/${assistantId}/files`),
getFileById: (assistantId: string, fileId: string) =>
buildUrl(`/assistants/${assistantId}/files/${fileId}`),
uploadFile: (assistantId: string) =>
buildUrl(`/assistants/${assistantId}/files`),
deleteFile: (assistantId: string, fileId: string) =>
buildUrl(`/assistants/${assistantId}/files/${fileId}`),
getKnowledge: (assistantId: string) =>
buildUrl(`/assistants/${assistantId}/knowledge`),
addKnowledge: (assistantId: string, fileName: string) =>
buildUrl(`/assistants/${assistantId}/knowledge/${fileName}`),
deleteKnowledge: (assistantId: string, fileName: string) =>
buildUrl(`/assistants/${assistantId}/knowledge/${fileName}`),
},
knowledgeSources: {
getKnowledgeSources: (
Expand Down Expand Up @@ -189,10 +162,8 @@ export const ApiRoutes = {
updateEnv: (entityId: string) => buildUrl(`/agents/${entityId}/env`),
},
credentials: {
getCredentialsForEntity: (
namespace: CredentialNamespace,
entityId: string
) => buildUrl(`/${namespace}/${entityId}/credentials`),
getCredentials: (namespace: CredentialNamespace, entityId: string) =>
buildUrl(`/${namespace}/${entityId}/credentials`),
deleteCredential: (
namespace: CredentialNamespace,
entityId: string,
Expand Down
Loading