mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 16:56:22 +02:00
- Added checks for expired authentication in Google Drive file creation and deletion tools, returning appropriate error messages for re-authentication. - Updated the Google Drive tool metadata service to track account health and persist authentication status. - Enhanced UI components to display authentication errors and differentiate between valid and expired accounts, improving user experience during file operations.
611 lines
18 KiB
TypeScript
611 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
|
import { CornerDownLeftIcon, Pen } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { PlateEditor } from "@/components/editor/plate-editor";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { useSetAtom } from "jotai";
|
|
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|
|
|
interface LinearLabel {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
interface LinearState {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
color: string;
|
|
position: number;
|
|
}
|
|
|
|
interface LinearMember {
|
|
id: string;
|
|
name: string;
|
|
displayName: string;
|
|
email: string;
|
|
active: boolean;
|
|
}
|
|
|
|
interface LinearTeam {
|
|
id: string;
|
|
name: string;
|
|
key: string;
|
|
states: LinearState[];
|
|
members: LinearMember[];
|
|
labels: LinearLabel[];
|
|
}
|
|
|
|
interface LinearPriority {
|
|
priority: number;
|
|
label: string;
|
|
}
|
|
|
|
interface LinearWorkspace {
|
|
id: number;
|
|
name: string;
|
|
organization_name: string;
|
|
teams: LinearTeam[];
|
|
priorities: LinearPriority[];
|
|
auth_expired?: boolean;
|
|
}
|
|
|
|
interface InterruptResult {
|
|
__interrupt__: true;
|
|
__decided__?: "approve" | "reject" | "edit";
|
|
action_requests: Array<{
|
|
name: string;
|
|
args: Record<string, unknown>;
|
|
}>;
|
|
review_configs: Array<{
|
|
action_name: string;
|
|
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
|
}>;
|
|
interrupt_type?: string;
|
|
context?: {
|
|
workspaces?: LinearWorkspace[];
|
|
error?: string;
|
|
};
|
|
}
|
|
|
|
interface SuccessResult {
|
|
status: "success";
|
|
issue_id: string;
|
|
identifier: string;
|
|
url: string;
|
|
message?: string;
|
|
}
|
|
|
|
interface ErrorResult {
|
|
status: "error";
|
|
message: string;
|
|
}
|
|
|
|
interface AuthErrorResult {
|
|
status: "auth_error";
|
|
message: string;
|
|
connector_id?: number;
|
|
connector_type: string;
|
|
}
|
|
|
|
type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
|
|
|
|
function isInterruptResult(result: unknown): result is InterruptResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"__interrupt__" in result &&
|
|
(result as InterruptResult).__interrupt__ === true
|
|
);
|
|
}
|
|
|
|
function isErrorResult(result: unknown): result is ErrorResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"status" in result &&
|
|
(result as ErrorResult).status === "error"
|
|
);
|
|
}
|
|
|
|
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"status" in result &&
|
|
(result as AuthErrorResult).status === "auth_error"
|
|
);
|
|
}
|
|
|
|
function ApprovalCard({
|
|
args,
|
|
interruptData,
|
|
onDecision,
|
|
}: {
|
|
args: { title: string; description?: string };
|
|
interruptData: InterruptResult;
|
|
onDecision: (decision: {
|
|
type: "approve" | "reject" | "edit";
|
|
message?: string;
|
|
edited_action?: { name: string; args: Record<string, unknown> };
|
|
}) => void;
|
|
}) {
|
|
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
|
|
interruptData.__decided__ ?? null
|
|
);
|
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
|
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
|
|
|
|
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
|
|
const [selectedTeamId, setSelectedTeamId] = useState("");
|
|
const [selectedStateId, setSelectedStateId] = useState("__none__");
|
|
const [selectedAssigneeId, setSelectedAssigneeId] = useState("__none__");
|
|
const [selectedPriority, setSelectedPriority] = useState("0");
|
|
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
|
|
|
|
const workspaces = interruptData.context?.workspaces ?? [];
|
|
const validWorkspaces = useMemo(() => workspaces.filter((w) => !w.auth_expired), [workspaces]);
|
|
const expiredWorkspaces = useMemo(() => workspaces.filter((w) => w.auth_expired), [workspaces]);
|
|
|
|
const selectedWorkspace = useMemo(
|
|
() => validWorkspaces.find((w) => String(w.id) === selectedWorkspaceId) ?? null,
|
|
[validWorkspaces, selectedWorkspaceId]
|
|
);
|
|
|
|
const selectedTeam = useMemo(
|
|
() => selectedWorkspace?.teams.find((t) => t.id === selectedTeamId) ?? null,
|
|
[selectedWorkspace, selectedTeamId]
|
|
);
|
|
|
|
const isTitleValid = (args.title ?? "").trim().length > 0;
|
|
const canApprove = !!selectedWorkspaceId && !!selectedTeamId && isTitleValid;
|
|
|
|
const reviewConfig = interruptData.review_configs[0];
|
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
|
const canEdit = allowedDecisions.includes("edit");
|
|
|
|
const buildFinalArgs = useCallback((overrides?: { title?: string; description?: string }) => {
|
|
return {
|
|
title: overrides?.title ?? args.title,
|
|
description: overrides?.description ?? args.description ?? null,
|
|
connector_id: selectedWorkspaceId ? Number(selectedWorkspaceId) : null,
|
|
team_id: selectedTeamId || null,
|
|
state_id: selectedStateId === "__none__" ? null : selectedStateId,
|
|
assignee_id: selectedAssigneeId === "__none__" ? null : selectedAssigneeId,
|
|
priority: Number(selectedPriority),
|
|
label_ids: selectedLabelIds,
|
|
};
|
|
}, [args.title, args.description, selectedWorkspaceId, selectedTeamId, selectedStateId, selectedAssigneeId, selectedPriority, selectedLabelIds]);
|
|
|
|
const handleApprove = useCallback(() => {
|
|
if (decided || isPanelOpen || !canApprove) return;
|
|
if (!allowedDecisions.includes("approve")) return;
|
|
setDecided("approve");
|
|
onDecision({
|
|
type: "approve",
|
|
edited_action: {
|
|
name: interruptData.action_requests[0].name,
|
|
args: buildFinalArgs(),
|
|
},
|
|
});
|
|
}, [decided, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, buildFinalArgs]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
handleApprove();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, [handleApprove]);
|
|
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
|
|
<div>
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{decided === "reject"
|
|
? "Linear Issue Rejected"
|
|
: decided === "approve" || decided === "edit"
|
|
? "Linear Issue Approved"
|
|
: "Create Linear Issue"}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{decided === "reject"
|
|
? "Issue creation was cancelled"
|
|
: decided === "edit"
|
|
? "Issue creation is in progress with your changes"
|
|
: decided === "approve"
|
|
? "Issue creation is in progress"
|
|
: "Requires your approval to proceed"}
|
|
</p>
|
|
</div>
|
|
{!decided && canEdit && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
|
|
onClick={() => {
|
|
setIsPanelOpen(true);
|
|
openHitlEditPanel({
|
|
title: args.title ?? "",
|
|
content: args.description ?? "",
|
|
toolName: "Linear Issue",
|
|
onSave: (newTitle, newDescription) => {
|
|
setIsPanelOpen(false);
|
|
setDecided("edit");
|
|
onDecision({
|
|
type: "edit",
|
|
edited_action: {
|
|
name: interruptData.action_requests[0].name,
|
|
args: buildFinalArgs({ title: newTitle, description: newDescription }),
|
|
},
|
|
});
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<Pen className="size-3.5" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Context section */}
|
|
{!decided && (
|
|
<>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4 space-y-4 select-none">
|
|
{interruptData.context?.error ? (
|
|
<p className="text-sm text-destructive">{interruptData.context.error}</p>
|
|
) : (
|
|
<>
|
|
{workspaces.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
Linear Account <span className="text-destructive">*</span>
|
|
</p>
|
|
<Select
|
|
value={selectedWorkspaceId}
|
|
onValueChange={(v) => {
|
|
setSelectedWorkspaceId(v);
|
|
setSelectedTeamId("");
|
|
setSelectedStateId("__none__");
|
|
setSelectedAssigneeId("__none__");
|
|
setSelectedPriority("0");
|
|
setSelectedLabelIds([]);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select an account" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{validWorkspaces.map((w) => (
|
|
<SelectItem key={w.id} value={String(w.id)}>
|
|
{w.name}
|
|
</SelectItem>
|
|
))}
|
|
{expiredWorkspaces.map((w) => (
|
|
<div
|
|
key={w.id}
|
|
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
|
|
>
|
|
{w.name} (expired, retry after re-auth)
|
|
</div>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{selectedWorkspace && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
Team <span className="text-destructive">*</span>
|
|
</p>
|
|
<Select
|
|
value={selectedTeamId}
|
|
onValueChange={(v) => {
|
|
setSelectedTeamId(v);
|
|
const newTeam = selectedWorkspace.teams.find((t) => t.id === v);
|
|
setSelectedStateId(newTeam?.states?.[0]?.id ?? "__none__");
|
|
setSelectedAssigneeId("__none__");
|
|
setSelectedLabelIds([]);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select a team" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectedWorkspace.teams.map((t) => (
|
|
<SelectItem key={t.id} value={t.id}>
|
|
{t.name} ({t.key})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedTeam && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">State</p>
|
|
<Select value={selectedStateId} onValueChange={setSelectedStateId}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Default" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectedTeam.states.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>
|
|
{s.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Assignee</p>
|
|
<Select value={selectedAssigneeId} onValueChange={setSelectedAssigneeId}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Unassigned" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">Unassigned</SelectItem>
|
|
{selectedTeam.members
|
|
.filter((m) => m.active)
|
|
.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
{m.name} ({m.email})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Priority</p>
|
|
<Select value={selectedPriority} onValueChange={setSelectedPriority}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="No priority" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectedWorkspace.priorities.map((p) => (
|
|
<SelectItem key={p.priority} value={String(p.priority)}>
|
|
{p.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedTeam.labels.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Labels</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{selectedTeam.labels.map((label) => {
|
|
const isSelected = selectedLabelIds.includes(label.id);
|
|
return (
|
|
<button
|
|
key={label.id}
|
|
type="button"
|
|
onClick={() =>
|
|
setSelectedLabelIds((prev) =>
|
|
isSelected
|
|
? prev.filter((id) => id !== label.id)
|
|
: [...prev, label.id]
|
|
)
|
|
}
|
|
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-opacity ${
|
|
isSelected
|
|
? "opacity-100 ring-2 ring-foreground/30"
|
|
: "opacity-50 hover:opacity-80"
|
|
}`}
|
|
style={{
|
|
backgroundColor: `${label.color}33`,
|
|
color: label.color,
|
|
}}
|
|
>
|
|
<span
|
|
className="size-1.5 rounded-full"
|
|
style={{ backgroundColor: label.color }}
|
|
/>
|
|
{label.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Content preview */}
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 pt-3">
|
|
{args.title != null && (
|
|
<p className="text-sm font-medium text-foreground">{args.title}</p>
|
|
)}
|
|
{args.description != null && (
|
|
<div
|
|
className="max-h-[7rem] overflow-hidden text-sm"
|
|
style={{
|
|
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
|
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
|
}}
|
|
>
|
|
<PlateEditor
|
|
markdown={args.description}
|
|
readOnly
|
|
preset="readonly"
|
|
editorVariant="none"
|
|
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons - only shown when pending */}
|
|
{!decided && (
|
|
<>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
|
{allowedDecisions.includes("approve") && (
|
|
<Button
|
|
size="sm"
|
|
className="rounded-lg gap-1.5"
|
|
onClick={handleApprove}
|
|
disabled={!canApprove}
|
|
>
|
|
Approve
|
|
<CornerDownLeftIcon className="size-3 opacity-60" />
|
|
</Button>
|
|
)}
|
|
{allowedDecisions.includes("reject") && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="rounded-lg text-muted-foreground"
|
|
onClick={() => {
|
|
setDecided("reject");
|
|
onDecision({ type: "reject", message: "User rejected the action." });
|
|
}}
|
|
>
|
|
Reject
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
|
|
<div className="px-5 pt-5 pb-4">
|
|
<p className="text-sm font-semibold text-destructive">
|
|
All Linear accounts expired
|
|
</p>
|
|
</div>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4">
|
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
|
|
<div className="px-5 pt-5 pb-4">
|
|
<p className="text-sm font-semibold text-destructive">Failed to create Linear issue</p>
|
|
</div>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4">
|
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
|
|
<div className="px-5 pt-5 pb-4">
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{result.message || "Linear issue created successfully"}
|
|
</p>
|
|
</div>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4 space-y-2 text-xs">
|
|
<div>
|
|
<span className="font-medium text-muted-foreground">Identifier: </span>
|
|
<span>{result.identifier}</span>
|
|
</div>
|
|
{result.url && (
|
|
<div>
|
|
<a
|
|
href={result.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
Open in Linear
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const CreateLinearIssueToolUI = makeAssistantToolUI<
|
|
{ title: string; description?: string },
|
|
CreateLinearIssueResult
|
|
>({
|
|
toolName: "create_linear_issue",
|
|
render: function CreateLinearIssueUI({ args, result, status }) {
|
|
if (status.type === "running") {
|
|
return (
|
|
<div className="my-4 flex max-w-lg items-center gap-3 rounded-2xl border bg-muted/30 px-5 py-4">
|
|
<Spinner size="sm" className="text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">Preparing Linear issue...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!result) return null;
|
|
|
|
if (isInterruptResult(result)) {
|
|
return (
|
|
<ApprovalCard
|
|
args={args}
|
|
interruptData={result}
|
|
onDecision={(decision) => {
|
|
window.dispatchEvent(
|
|
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
|
);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"status" in result &&
|
|
(result as { status: string }).status === "rejected"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
|
|
if (isErrorResult(result)) return <ErrorCard result={result} />;
|
|
|
|
return <SuccessCard result={result as SuccessResult} />;
|
|
},
|
|
});
|