google-slides-manager.tsx7.1 KBView on GitHub 'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ExternalLink, Loader2, Plus, Presentation, Trash2 } from 'lucide-react';
import { authClient } from '@/modules/auth/utils/auth-client';
import { GoogleDriveIcon } from '@/components/icons/google-drive-icon';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import { useGooglePicker } from './use-google-picker';
const MIME_PRESENTATION = 'application/vnd.google-apps.presentation';
const MIME_DOCUMENT = 'application/vnd.google-apps.document';
/**
* What the agent can be pointed at in Drive. Docs belong here as much as Slides: customers
* keep living playbooks, FAQs and process notes in Docs, and the KB entry is just a
* reference ({kind, fileId, mimeType}) that the read path already resolves per MIME type.
*/
const KB_DRIVE_MIMES = [MIME_PRESENTATION, MIME_DOCUMENT];
/** Drive's viewer URL differs per native type — a Doc opened as /presentation/ 404s. */
function driveFileUrl(mimeType: string, fileId: string): string {
if (mimeType === MIME_PRESENTATION) {
return `https://docs.google.com/presentation/d/${fileId}`;
}
if (mimeType === MIME_DOCUMENT) {
return `https://docs.google.com/document/d/${fileId}`;
}
return `https://drive.google.com/file/d/${fileId}`;
}
export function GoogleSlidesManager() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: pickerConfig } = useQuery(
trpc.presentations.getPickerConfig.queryOptions(undefined, { retry: false }),
);
// Load linked Drive files from the KB (google_drive entries Cedar can read)
const { data: kbData, isLoading } = useQuery(trpc.kb.listKbDocuments.queryOptions());
const driveFiles = (kbData?.documents ?? []).filter(
(doc) =>
typeof doc.metadata === 'object' &&
doc.metadata !== null &&
(doc.metadata as { kind?: unknown }).kind === 'google_drive' &&
KB_DRIVE_MIMES.includes((doc.metadata as { mimeType?: string }).mimeType ?? ''),
);
const { mutateAsync: createFromDrive, isPending: isAdding } = useMutation(
trpc.kb.createKbFromGoogleDrive.mutationOptions({
onSuccess: () => queryClient.invalidateQueries(trpc.kb.listKbDocuments.queryOptions()),
}),
);
const { mutate: deleteKbDoc, isPending: isDeleting } = useMutation(
trpc.kb.deleteKbDocument.mutationOptions({
onSuccess: () => queryClient.invalidateQueries(trpc.kb.listKbDocuments.queryOptions()),
}),
);
const { openPicker, isLoading: isPickerLoading } = useGooglePicker({
multiselect: true,
mimeTypes: KB_DRIVE_MIMES,
onPick: async (files) => {
const existingFileIds = new Set(
driveFiles.map((p) => (p.metadata as { fileId?: string }).fileId),
);
const newFiles = files.filter((f) => !existingFileIds.has(f.id));
if (newFiles.length === 0) return;
for (const file of newFiles) {
try {
await createFromDrive({
fileId: file.id,
mimeType: file.mimeType ?? MIME_PRESENTATION,
name: file.name,
});
} catch (err) {
toast.error(
`Failed to add "${file.name}": ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
}
},
});
const isConnected = !!pickerConfig;
const needsReauth = pickerConfig?.needsReauth === true;
if (needsReauth) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
Google Drive access required
</p>
<p className="mt-1 text-sm text-amber-700 dark:text-amber-400">
Your Google account was connected before Drive access was added. Re-authorize to link
Google Docs and Slides.
</p>
<Button
size="sm"
className="mt-3"
onClick={() =>
authClient.linkSocial({
provider: 'google',
callbackURL: `${window.location.origin}/settings/connections?tab=slides`,
})
}
>
Re-authorize Google
</Button>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{isConnected
? 'Link Google Docs and Slides to give the agent access to them.'
: 'Connect a Google account above to enable this feature.'}
</p>
{isConnected && (
<Button
size="sm"
variant="outline"
onClick={openPicker}
disabled={isPickerLoading || isAdding}
>
{isAdding ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<Plus className="mr-1.5 size-3.5" />
)}
Add file
</Button>
)}
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
) : driveFiles.length === 0 ? (
<p className="text-muted-foreground text-sm italic">
No files linked yet. The agent can still create new ones.
</p>
) : (
<ul className="space-y-1.5">
{driveFiles.map((p) => {
const meta = p.metadata as { fileId: string; mimeType: string };
return (
<li
key=[redacted]
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<div className="flex min-w-0 items-center gap-2">
{meta.mimeType === MIME_PRESENTATION ? (
<Presentation className="text-muted-foreground size-4 shrink-0" />
) : (
<GoogleDriveIcon className="size-4 shrink-0" />
)}
<span className="truncate">{p.name}</span>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" className="size-7" asChild>
<a
href={driveFileUrl(meta.mimeType, meta.fileId)}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-3.5" />
</a>
</Button>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive size-7"
disabled={isDeleting}
onClick={() => deleteKbDoc({ id: p.id })}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}