add jira connector implementation in the web

This commit is contained in:
CREDO23 2025-07-24 11:58:41 +02:00
parent cd05a06a91
commit 6bced733b2
9 changed files with 2740 additions and 2133 deletions

View file

@ -0,0 +1 @@
{"2d0ec64d93969318101ee479b664221b32241665":{"files":{"surfsense_web/lib/connectors/utils.ts":["RXwmTdu3JAyxa1ApFuYJiSRHfZo=",true],"surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx":["jZynb8hLm5uq1viyFK9UMcRClD8=",true],"surfsense_web/app/dashboard/[search_space_id]/researcher/[chat_id]/page.tsx":["LEFIcQIvBUtbTE9PuuJI0WqzdVw=",true]},"modified":1753351069225}}

View file

@ -17,7 +17,6 @@ You are SurfSense, an advanced AI research assistant that provides detailed, wel
- LINEAR_CONNECTOR: "Linear project issues and discussions" (personal project management)
- JIRA_CONNECTOR: "Jira project issues, tickets, and comments" (personal project tracking)
- DISCORD_CONNECTOR: "Discord server conversations and shared content" (personal community communications)
- DISCORD_CONNECTOR: "Discord server messages and channels" (personal community interactions)
- TAVILY_API: "Tavily search API results" (personalized search results)
- LINKUP_API: "Linkup search API results" (personalized search results)
</knowledge_sources>

View file

@ -181,6 +181,26 @@ export default function EditConnectorPage() {
/>
)}
{/* == Jira == */}
{connector.connector_type === "JIRA_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_BASE_URL"
fieldLabel="Jira Base URL"
fieldDescription="Update your Jira instance URL if needed."
placeholder="https://yourcompany.atlassian.net"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_PERSONAL_ACCESS_TOKEN"
fieldLabel="Jira Personal Access Token"
fieldDescription="Update your Jira Personal Access Token if needed."
placeholder="Your Jira Personal Access Token"
/>
</div>
)}
{/* == Linkup == */}
{connector.connector_type === "LINKUP_API" && (
<EditSimpleTokenForm
@ -202,7 +222,6 @@ export default function EditConnectorPage() {
placeholder="Bot token..."
/>
)}
</CardContent>
<CardFooter className="border-t pt-6">
<Button

View file

@ -9,7 +9,10 @@ import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors, SearchSourceConnector } from "@/hooks/useSearchSourceConnectors";
import {
useSearchSourceConnectors,
SearchSourceConnector,
} from "@/hooks/useSearchSourceConnectors";
import {
Form,
FormControl,
@ -28,11 +31,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
// Define the form schema with Zod
const apiConnectorFormSchema = z.object({
@ -47,13 +46,15 @@ const apiConnectorFormSchema = z.object({
// Helper function to get connector type display name
const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = {
"SERPER_API": "Serper API",
"TAVILY_API": "Tavily API",
"SLACK_CONNECTOR": "Slack Connector",
"NOTION_CONNECTOR": "Notion Connector",
"GITHUB_CONNECTOR": "GitHub Connector",
"DISCORD_CONNECTOR": "Discord Connector",
"LINKUP_API": "Linkup",
SERPER_API: "Serper API",
TAVILY_API: "Tavily API",
SLACK_CONNECTOR: "Slack Connector",
NOTION_CONNECTOR: "Notion Connector",
GITHUB_CONNECTOR: "GitHub Connector",
LINEAR_CONNECTOR: "Linear Connector",
JIRA_CONNECTOR: "Jira Connector",
DISCORD_CONNECTOR: "Discord Connector",
LINKUP_API: "Linkup",
// Add other connector types here as needed
};
return typeMap[type] || type;
@ -69,7 +70,9 @@ export default function EditConnectorPage() {
const connectorId = parseInt(params.connector_id as string, 10);
const { connectors, updateConnector } = useSearchSourceConnectors();
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
const [connector, setConnector] = useState<SearchSourceConnector | null>(
null,
);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
// console.log("connector", connector);
@ -85,20 +88,20 @@ export default function EditConnectorPage() {
// Get API key field name based on connector type
const getApiKeyFieldName = (connectorType: string): string => {
const fieldMap: Record<string, string> = {
"SERPER_API": "SERPER_API_KEY",
"TAVILY_API": "TAVILY_API_KEY",
"SLACK_CONNECTOR": "SLACK_BOT_TOKEN",
"NOTION_CONNECTOR": "NOTION_INTEGRATION_TOKEN",
"GITHUB_CONNECTOR": "GITHUB_PAT",
"DISCORD_CONNECTOR": "DISCORD_BOT_TOKEN",
"LINKUP_API": "LINKUP_API_KEY"
SERPER_API: "SERPER_API_KEY",
TAVILY_API: "TAVILY_API_KEY",
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
GITHUB_CONNECTOR: "GITHUB_PAT",
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
LINKUP_API: "LINKUP_API_KEY",
};
return fieldMap[connectorType] || "";
};
// Find connector in the list
useEffect(() => {
const currentConnector = connectors.find(c => c.id === connectorId);
const currentConnector = connectors.find((c) => c.id === connectorId);
if (currentConnector) {
setConnector(currentConnector);
@ -150,7 +153,9 @@ export default function EditConnectorPage() {
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error updating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to update connector");
toast.error(
error instanceof Error ? error.message : "Failed to update connector",
);
} finally {
setIsSubmitting(false);
}
@ -186,24 +191,30 @@ export default function EditConnectorPage() {
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
Edit{" "}
{connector
? getConnectorTypeDisplay(connector.connector_type)
: ""}{" "}
Connector
</CardTitle>
<CardDescription>
Update your connector settings.
</CardDescription>
<CardDescription>Update your connector settings.</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>API Key Security</AlertTitle>
<AlertDescription>
Your API key is stored securely. For security reasons, we don't display your existing API key.
If you don't update the API key field, your existing key will be preserved.
Your API key is stored securely. For security reasons, we don't
display your existing API key. If you don't update the API key
field, your existing key will be preserved.
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
@ -245,7 +256,8 @@ export default function EditConnectorPage() {
? "Enter new Slack Bot Token (optional)"
: connector?.connector_type === "NOTION_CONNECTOR"
? "Enter new Notion Token (optional)"
: connector?.connector_type === "GITHUB_CONNECTOR"
: connector?.connector_type ===
"GITHUB_CONNECTOR"
? "Enter new GitHub PAT (optional)"
: connector?.connector_type === "LINKUP_API"
? "Enter new Linkup API Key (optional)"

View file

@ -0,0 +1,448 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// Define the form schema with Zod
const jiraConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message:
"Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("jira");
},
{
message: "Please enter a valid Jira instance URL",
},
),
personal_access_token: z.string().min(10, {
message: "Jira Personal Access Token is required and must be valid.",
}),
});
// Define the type for the form values
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
export default function JiraConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors();
// Initialize the form
const form = useForm<JiraConnectorFormValues>({
resolver: zodResolver(jiraConnectorFormSchema),
defaultValues: {
name: "Jira Connector",
base_url: "",
personal_access_token: "",
},
});
// Handle form submission
const onSubmit = async (values: JiraConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "JIRA_CONNECTOR",
config: {
JIRA_BASE_URL: values.base_url,
JIRA_PERSONAL_ACCESS_TOKEN: values.personal_access_token,
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("Jira connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(
error instanceof Error ? error.message : "Failed to create connector",
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() =>
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Connect Jira Instance
</CardTitle>
<CardDescription>
Integrate with Jira to search and retrieve information from
your issues, tickets, and comments. This connector can index
your Jira content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
<AlertDescription>
You'll need a Jira Personal Access Token to use this
connector. You can create one from{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Jira Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Jira Instance URL</FormLabel>
<FormControl>
<Input
placeholder="https://yourcompany.atlassian.net"
{...field}
/>
</FormControl>
<FormDescription>
Your Jira instance URL. For Atlassian Cloud, this is
typically https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="personal_access_token"
render={({ field }) => (
<FormItem>
<FormLabel>Personal Access Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Jira Personal Access Token"
{...field}
/>
</FormControl>
<FormDescription>
Your Jira Personal Access Token will be encrypted
and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Jira
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">
What you get with Jira integration:
</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through all your Jira issues and tickets</li>
<li>
Access issue descriptions, comments, and full discussion
threads
</li>
<li>
Connect your team's project management directly to your
search space
</li>
<li>
Keep your search results up-to-date with latest Jira content
</li>
<li>
Index your Jira issues for enhanced search capabilities
</li>
<li>
Search by issue keys, status, priority, and assignee
information
</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Jira Connector Documentation
</CardTitle>
<CardDescription>
Learn how to set up and use the Jira connector to index your
project management data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Jira connector uses the Jira REST API to fetch all
issues and comments that the Personal Access Token has
access to within your Jira instance.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
For follow up indexing runs, the connector retrieves
issues and comments that have been updated since the last
indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates
should appear in your search results within minutes.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium">
Authorization
</AccordionTrigger>
<AccordionContent className="space-y-4">
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription>
You only need read access for this connector to work.
The Personal Access Token will only be used to read
your Jira data.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">
Step 1: Create a Personal Access Token
</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Atlassian account</li>
<li>
Navigate to{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://id.atlassian.com/manage-profile/security/api-tokens
</a>
</li>
<li>
Click <strong>Create API token</strong>
</li>
<li>
Enter a label for your token (like "SurfSense
Connector")
</li>
<li>
Click <strong>Create</strong>
</li>
<li>
Copy the generated token as it will only be shown
once
</li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-muted-foreground mb-3">
The Personal Access Token will have access to all
projects and issues that your user account can see.
Make sure your account has appropriate permissions
for the projects you want to index.
</p>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Data Privacy</AlertTitle>
<AlertDescription>
Only issues, comments, and basic metadata will be
indexed. Jira attachments and linked files are not
indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">
Indexing
</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the{" "}
<strong>Jira</strong> Connector.
</li>
<li>
Enter your <strong>Jira Instance URL</strong> (e.g.,
https://yourcompany.atlassian.net)
</li>
<li>
Place your <strong>Personal Access Token</strong> in
the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the
connection.
</li>
<li>
Once connected, your Jira issues will be indexed
automatically.
</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription>
<p className="mb-2">
The Jira connector indexes the following data:
</p>
<ul className="list-disc pl-5">
<li>Issue keys and summaries (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments and discussion threads</li>
<li>
Issue status, priority, and type information
</li>
<li>Assignee and reporter information</li>
<li>Project information</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,8 +1,17 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
IconBrandDiscord,
IconBrandGithub,
@ -67,23 +76,26 @@ const connectorCategories: ConnectorCategory[] = [
{
id: "slack-connector",
title: "Slack",
description: "Connect to your Slack workspace to access messages and channels.",
description:
"Connect to your Slack workspace to access messages and channels.",
icon: <IconBrandSlack className="h-6 w-6" />,
status: "available",
},
{
id: "ms-teams",
title: "Microsoft Teams",
description: "Connect to Microsoft Teams to access your team's conversations.",
description:
"Connect to Microsoft Teams to access your team's conversations.",
icon: <IconBrandWindows className="h-6 w-6" />,
status: "coming-soon",
},
{
id: "discord-connector",
title: "Discord",
description: "Connect to Discord servers to access messages and channels.",
description:
"Connect to Discord servers to access messages and channels.",
icon: <IconBrandDiscord className="h-6 w-6" />,
status: "available"
status: "available",
},
],
},
@ -94,16 +106,18 @@ const connectorCategories: ConnectorCategory[] = [
{
id: "linear-connector",
title: "Linear",
description: "Connect to Linear to search issues, comments and project data.",
description:
"Connect to Linear to search issues, comments and project data.",
icon: <IconLayoutKanban className="h-6 w-6" />,
status: "available",
},
{
id: "jira-connector",
title: "Jira",
description: "Connect to Jira to search issues, tickets and project data.",
description:
"Connect to Jira to search issues, tickets and project data.",
icon: <IconTicket className="h-6 w-6" />,
status: "coming-soon",
status: "available",
},
],
},
@ -114,14 +128,16 @@ const connectorCategories: ConnectorCategory[] = [
{
id: "notion-connector",
title: "Notion",
description: "Connect to your Notion workspace to access pages and databases.",
description:
"Connect to your Notion workspace to access pages and databases.",
icon: <IconBrandNotion className="h-6 w-6" />,
status: "available",
},
{
id: "github-connector",
title: "GitHub",
description: "Connect a GitHub PAT to index code and docs from accessible repositories.",
description:
"Connect a GitHub PAT to index code and docs from accessible repositories.",
icon: <IconBrandGithub className="h-6 w-6" />,
status: "available",
},
@ -141,7 +157,8 @@ const connectorCategories: ConnectorCategory[] = [
{
id: "zoom",
title: "Zoom",
description: "Connect to Zoom to access meeting recordings and transcripts.",
description:
"Connect to Zoom to access meeting recordings and transcripts.",
icon: <IconBrandZoom className="h-6 w-6" />,
status: "coming-soon",
},
@ -152,7 +169,7 @@ const connectorCategories: ConnectorCategory[] = [
// Animation variants
const fadeIn = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } }
visible: { opacity: 1, transition: { duration: 0.4 } },
};
const staggerContainer = {
@ -160,9 +177,9 @@ const staggerContainer = {
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
staggerChildren: 0.1,
},
},
};
const cardVariants = {
@ -173,30 +190,36 @@ const cardVariants = {
transition: {
type: "spring",
stiffness: 260,
damping: 20
}
damping: 20,
},
},
hover: {
scale: 1.02,
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
boxShadow:
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
transition: {
type: "spring",
stiffness: 400,
damping: 10
}
}
damping: 10,
},
},
};
export default function ConnectorsPage() {
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [expandedCategories, setExpandedCategories] = useState<string[]>(["search-engines", "knowledge-bases", "project-management", "team-chats"]);
const [expandedCategories, setExpandedCategories] = useState<string[]>([
"search-engines",
"knowledge-bases",
"project-management",
"team-chats",
]);
const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev =>
setExpandedCategories((prev) =>
prev.includes(categoryId)
? prev.filter(id => id !== categoryId)
: [...prev, categoryId]
? prev.filter((id) => id !== categoryId)
: [...prev, categoryId],
);
};
@ -207,7 +230,7 @@ export default function ConnectorsPage() {
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1]
ease: [0.22, 1, 0.36, 1],
}}
className="mb-12 text-center"
>
@ -215,7 +238,8 @@ export default function ConnectorsPage() {
Connect Your Tools
</h1>
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
Integrate with your favorite services to enhance your research capabilities.
Integrate with your favorite services to enhance your research
capabilities.
</p>
</motion.div>
@ -239,9 +263,17 @@ export default function ConnectorsPage() {
<div className="flex items-center justify-between space-x-4 p-4">
<h3 className="text-xl font-semibold">{category.title}</h3>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted">
<Button
variant="ghost"
size="sm"
className="w-9 p-0 hover:bg-muted"
>
<motion.div
animate={{ rotate: expandedCategories.includes(category.id) ? 180 : 0 }}
animate={{
rotate: expandedCategories.includes(category.id)
? 180
: 0,
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<IconChevronDown className="h-5 w-5" />
@ -279,14 +311,22 @@ export default function ConnectorsPage() {
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{connector.title}</h3>
<h3 className="font-medium">
{connector.title}
</h3>
{connector.status === "coming-soon" && (
<Badge variant="outline" className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800">
<Badge
variant="outline"
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
>
Coming soon
</Badge>
)}
{connector.status === "connected" && (
<Badge variant="outline" className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800">
<Badge
variant="outline"
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
>
Connected
</Badge>
)}
@ -301,28 +341,45 @@ export default function ConnectorsPage() {
</CardContent>
<CardFooter className="mt-auto pt-2">
{connector.status === 'available' && (
<Link href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`} className="w-full">
<Button variant="default" className="w-full group">
{connector.status === "available" && (
<Link
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
className="w-full"
>
<Button
variant="default"
className="w-full group"
>
<span>Connect</span>
<motion.div
className="ml-1"
initial={{ x: 0 }}
whileHover={{ x: 3 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }}
transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
>
<IconChevronRight className="h-4 w-4" />
</motion.div>
</Button>
</Link>
)}
{connector.status === 'coming-soon' && (
<Button variant="outline" disabled className="w-full opacity-70">
{connector.status === "coming-soon" && (
<Button
variant="outline"
disabled
className="w-full opacity-70"
>
Coming Soon
</Button>
)}
{connector.status === 'connected' && (
<Button variant="outline" className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950">
{connector.status === "connected" && (
<Button
variant="outline"
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
>
Manage
</Button>
)}

View file

@ -103,6 +103,7 @@ type DocumentType =
| "YOUTUBE_VIDEO"
| "GITHUB_CONNECTOR"
| "LINEAR_CONNECTOR"
| "JIRA_CONNECTOR"
| "DISCORD_CONNECTOR";
/**
@ -982,12 +983,14 @@ const ChatPage = () => {
if (!message.annotations) return null;
// Get all TERMINAL_INFO annotations content
const terminalInfoAnnotations = (message.annotations as any[]).map(item => {
const terminalInfoAnnotations = (message.annotations as any[])
.map((item) => {
if (item.type === "TERMINAL_INFO") {
return item.content.map((a: any) => a.text)
return item.content.map((a: any) => a.text);
}
}).flat().filter(Boolean)
})
.flat()
.filter(Boolean);
// Render the content of the latest TERMINAL_INFO annotation
return terminalInfoAnnotations.map((item: any, idx: number) => (
@ -1328,29 +1331,41 @@ const ChatPage = () => {
}
// Fallback to the message content if no ANSWER annotation is available
return <MarkdownViewer
return (
<MarkdownViewer
content={message.content}
getCitationSource={(id) => getCitationSource(id, index)}
getCitationSource={(id) =>
getCitationSource(id, index)
}
type="ai"
/>;
/>
);
})()}
</div>
}
</div>
{/* Further Questions Section */}
{message.annotations && (() => {
{message.annotations &&
(() => {
// Get all FURTHER_QUESTIONS annotations
const furtherQuestionsAnnotations = (message.annotations as any[])
.filter(a => a.type === 'FURTHER_QUESTIONS');
const furtherQuestionsAnnotations = (
message.annotations as any[]
).filter((a) => a.type === "FURTHER_QUESTIONS");
// Get the latest FURTHER_QUESTIONS annotation
const latestFurtherQuestions = furtherQuestionsAnnotations.length > 0
? furtherQuestionsAnnotations[furtherQuestionsAnnotations.length - 1]
const latestFurtherQuestions =
furtherQuestionsAnnotations.length > 0
? furtherQuestionsAnnotations[
furtherQuestionsAnnotations.length - 1
]
: null;
// Only render if we have questions
if (!latestFurtherQuestions?.content || latestFurtherQuestions.content.length === 0) {
if (
!latestFurtherQuestions?.content ||
latestFurtherQuestions.content.length === 0
) {
return null;
}
@ -1364,13 +1379,24 @@ const ChatPage = () => {
<div className="bg-muted/50 border-b border-border/40 px-4 py-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Follow-up Questions
</h3>
<span className="text-xs text-muted-foreground bg-background/60 px-2 py-1 rounded-full border border-border/40">
{furtherQuestions.length} suggestion{furtherQuestions.length !== 1 ? 's' : ''}
{furtherQuestions.length} suggestion
{furtherQuestions.length !== 1 ? "s" : ""}
</span>
</div>
</div>
@ -1387,7 +1413,8 @@ const ChatPage = () => {
{/* Scrollable container */}
<div className="overflow-x-auto scrollbar-hover">
<div className="flex gap-2 py-1 px-6">
{furtherQuestions.map((question: any, qIndex: number) => (
{furtherQuestions.map(
(question: any, qIndex: number) => (
<Button
key={question.id || qIndex}
variant="outline"
@ -1396,18 +1423,26 @@ const ChatPage = () => {
onClick={() => {
// Set the input value and submit
handleInputChange({
target: { value: question.question }
target: {
value: question.question,
},
} as React.ChangeEvent<HTMLInputElement>);
// Small delay to ensure input is updated, then submit
setTimeout(() => {
const form = document.querySelector('form') as HTMLFormElement;
if (form && status === 'ready') {
const form =
document.querySelector(
"form",
) as HTMLFormElement;
if (
form &&
status === "ready"
) {
form.requestSubmit();
}
}, 50);
}}
disabled={status !== 'ready'}
disabled={status !== "ready"}
>
<span className="text-foreground group-hover:text-primary transition-colors">
{question.question}
@ -1427,7 +1462,8 @@ const ChatPage = () => {
/>
</svg>
</Button>
))}
),
)}
</div>
</div>
</div>

View file

@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
import {
ChevronDown,
Plus,
@ -12,77 +12,98 @@ import {
Webhook,
MessageCircle,
FileText,
} from 'lucide-react';
import { IconBrandNotion, IconBrandSlack, IconBrandYoutube, IconBrandGithub, IconLayoutKanban, IconLinkPlus, IconBrandDiscord } from "@tabler/icons-react";
import { Button } from '@/components/ui/button';
import { Connector, ResearchMode } from './types';
} from "lucide-react";
import {
IconBrandNotion,
IconBrandSlack,
IconBrandYoutube,
IconBrandGithub,
IconLayoutKanban,
IconLinkPlus,
IconBrandDiscord,
IconTicket,
} from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Connector, ResearchMode } from "./types";
// Helper function to get connector icon
export const getConnectorIcon = (connectorType: string) => {
const iconProps = { className: "h-4 w-4" };
switch (connectorType) {
case 'LINKUP_API':
case "LINKUP_API":
return <IconLinkPlus {...iconProps} />;
case 'LINEAR_CONNECTOR':
case "LINEAR_CONNECTOR":
return <IconLayoutKanban {...iconProps} />;
case 'GITHUB_CONNECTOR':
case "GITHUB_CONNECTOR":
return <IconBrandGithub {...iconProps} />;
case 'YOUTUBE_VIDEO':
case "YOUTUBE_VIDEO":
return <IconBrandYoutube {...iconProps} />;
case 'CRAWLED_URL':
case "CRAWLED_URL":
return <Globe {...iconProps} />;
case 'FILE':
case "FILE":
return <File {...iconProps} />;
case 'EXTENSION':
case "EXTENSION":
return <Webhook {...iconProps} />;
case 'SERPER_API':
case 'TAVILY_API':
case "SERPER_API":
case "TAVILY_API":
return <Link {...iconProps} />;
case 'SLACK_CONNECTOR':
case "SLACK_CONNECTOR":
return <IconBrandSlack {...iconProps} />;
case 'NOTION_CONNECTOR':
case "NOTION_CONNECTOR":
return <IconBrandNotion {...iconProps} />;
case 'DISCORD_CONNECTOR':
case "DISCORD_CONNECTOR":
return <IconBrandDiscord {...iconProps} />;
case 'DEEP':
case "JIRA_CONNECTOR":
return <IconTicket {...iconProps} />;
case "DEEP":
return <Sparkles {...iconProps} />;
case 'DEEPER':
case "DEEPER":
return <Microscope {...iconProps} />;
case 'DEEPEST':
case "DEEPEST":
return <Telescope {...iconProps} />;
default:
return <Search {...iconProps} />;
}
};
export const researcherOptions: { value: ResearchMode; label: string; icon: React.ReactNode }[] = [
export const researcherOptions: {
value: ResearchMode;
label: string;
icon: React.ReactNode;
}[] = [
{
value: 'QNA',
label: 'Q/A',
icon: getConnectorIcon('GENERAL')
value: "QNA",
label: "Q/A",
icon: getConnectorIcon("GENERAL"),
},
{
value: 'REPORT_GENERAL',
label: 'General',
icon: getConnectorIcon('GENERAL')
value: "REPORT_GENERAL",
label: "General",
icon: getConnectorIcon("GENERAL"),
},
{
value: 'REPORT_DEEP',
label: 'Deep',
icon: getConnectorIcon('DEEP')
value: "REPORT_DEEP",
label: "Deep",
icon: getConnectorIcon("DEEP"),
},
{
value: 'REPORT_DEEPER',
label: 'Deeper',
icon: getConnectorIcon('DEEPER')
value: "REPORT_DEEPER",
label: "Deeper",
icon: getConnectorIcon("DEEPER"),
},
]
];
/**
* Displays a small icon for a connector type
*/
export const ConnectorIcon = ({ type, index = 0 }: { type: string; index?: number }) => (
export const ConnectorIcon = ({
type,
index = 0,
}: {
type: string;
index?: number;
}) => (
<div
className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background"
style={{ zIndex: 10 - index }}
@ -109,15 +130,21 @@ type ConnectorButtonProps = {
/**
* Button that displays selected connectors and opens connector selection dialog
*/
export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources }: ConnectorButtonProps) => {
export const ConnectorButton = ({
selectedConnectors,
onClick,
connectorSources,
}: ConnectorButtonProps) => {
const totalConnectors = connectorSources.length;
const selectedCount = selectedConnectors.length;
const progressPercentage = (selectedCount / totalConnectors) * 100;
// Get the name of a single selected connector
const getSingleConnectorName = () => {
const connector = connectorSources.find(c => c.type === selectedConnectors[0]);
return connector?.name || '';
const connector = connectorSources.find(
(c) => c.type === selectedConnectors[0],
);
return connector?.name || "";
};
// Get display text based on selection count
@ -158,14 +185,18 @@ export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources
variant="outline"
className="h-8 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group"
onClick={onClick}
aria-label={selectedCount === 0 ? "Select Connectors" : `${selectedCount} connectors selected`}
aria-label={
selectedCount === 0
? "Select Connectors"
: `${selectedCount} connectors selected`
}
>
{/* Progress indicator */}
<div
className="absolute bottom-0 left-0 h-1 bg-primary"
style={{
width: `${progressPercentage}%`,
transition: 'width 0.3s ease'
transition: "width 0.3s ease",
}}
/>
@ -183,29 +214,32 @@ type ResearchModeControlProps = {
onChange: (value: ResearchMode) => void;
};
export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProps) => {
export const ResearchModeControl = ({
value,
onChange,
}: ResearchModeControlProps) => {
// Determine if we're in Q/A mode or Report mode
const isQnaMode = value === 'QNA';
const isReportMode = value.startsWith('REPORT_');
const isQnaMode = value === "QNA";
const isReportMode = value.startsWith("REPORT_");
// Get the current report sub-mode
const getCurrentReportMode = () => {
if (!isReportMode) return 'GENERAL';
return value.replace('REPORT_', '') as 'GENERAL' | 'DEEP' | 'DEEPER';
if (!isReportMode) return "GENERAL";
return value.replace("REPORT_", "") as "GENERAL" | "DEEP" | "DEEPER";
};
const reportSubOptions = [
{ value: 'GENERAL', label: 'General', icon: getConnectorIcon('GENERAL') },
{ value: 'DEEP', label: 'Deep', icon: getConnectorIcon('DEEP') },
{ value: 'DEEPER', label: 'Deeper', icon: getConnectorIcon('DEEPER') },
{ value: "GENERAL", label: "General", icon: getConnectorIcon("GENERAL") },
{ value: "DEEP", label: "Deep", icon: getConnectorIcon("DEEP") },
{ value: "DEEPER", label: "Deeper", icon: getConnectorIcon("DEEPER") },
];
const handleModeToggle = (mode: 'QNA' | 'REPORT') => {
if (mode === 'QNA') {
onChange('QNA');
const handleModeToggle = (mode: "QNA" | "REPORT") => {
if (mode === "QNA") {
onChange("QNA");
} else {
// Default to GENERAL for Report mode
onChange('REPORT_GENERAL');
onChange("REPORT_GENERAL");
}
};
@ -220,10 +254,10 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
<button
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
isQnaMode
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground"
}`}
onClick={() => handleModeToggle('QNA')}
onClick={() => handleModeToggle("QNA")}
aria-pressed={isQnaMode}
>
<MessageCircle className="h-3 w-3" />
@ -232,10 +266,10 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
<button
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
isReportMode
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground"
}`}
onClick={() => handleModeToggle('REPORT')}
onClick={() => handleModeToggle("REPORT")}
aria-pressed={isReportMode}
>
<FileText className="h-3 w-3" />
@ -251,8 +285,8 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
key={option.value}
className={`flex h-full items-center gap-1 px-2 text-xs font-medium transition-colors whitespace-nowrap ${
getCurrentReportMode() === option.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground"
}`}
onClick={() => handleReportSubModeChange(option.value)}
aria-pressed={getCurrentReportMode() === option.value}

View file

@ -1,14 +1,15 @@
// Helper function to get connector type display name
export const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = {
"SERPER_API": "Serper API",
"TAVILY_API": "Tavily API",
"SLACK_CONNECTOR": "Slack",
"NOTION_CONNECTOR": "Notion",
"GITHUB_CONNECTOR": "GitHub",
"LINEAR_CONNECTOR": "Linear",
"DISCORD_CONNECTOR": "Discord",
"LINKUP_API": "Linkup",
SERPER_API: "Serper API",
TAVILY_API: "Tavily API",
SLACK_CONNECTOR: "Slack",
NOTION_CONNECTOR: "Notion",
GITHUB_CONNECTOR: "GitHub",
LINEAR_CONNECTOR: "Linear",
JIRA_CONNECTOR: "Jira",
DISCORD_CONNECTOR: "Discord",
LINKUP_API: "Linkup",
};
return typeMap[type] || type;
};