Merge remote-tracking branch 'upstream/dev' into feat/composio

This commit is contained in:
Anish Sarkar 2026-01-23 14:35:17 +05:30
commit fae52345f8
65 changed files with 3291 additions and 153 deletions

View file

@ -39,12 +39,6 @@ export default function DashboardLayout({
icon: "SquareLibrary",
items: [],
},
{
title: "Logs",
url: `/dashboard/${search_space_id}/logs`,
icon: "Logs",
items: [],
},
];
return (

View file

@ -24,6 +24,7 @@ import {
// extractWriteTodosFromContent,
hydratePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
@ -32,7 +33,9 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { SaveMemoryToolUI, RecallMemoryToolUI } from "@/components/tool-ui/user-memory";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@ -258,6 +261,53 @@ export default function NewChatPage() {
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
// Live collaboration: sync session state and messages via Electric SQL
useChatSessionStateSync(threadId);
const { data: membersData } = useAtomValue(membersAtom);
const handleElectricMessagesUpdate = useCallback(
(
electricMessages: {
id: number;
thread_id: number;
role: string;
content: unknown;
author_id: string | null;
created_at: string;
}[]
) => {
if (isRunning) {
return;
}
setMessages((prev) => {
if (electricMessages.length < prev.length) {
return prev;
}
return electricMessages.map((msg) => {
const member = msg.author_id
? membersData?.find((m) => m.user_id === msg.author_id)
: null;
return convertToThreadMessage({
id: msg.id,
thread_id: msg.thread_id,
role: msg.role.toLowerCase() as "user" | "assistant" | "system",
content: msg.content,
author_id: msg.author_id,
created_at: msg.created_at,
author_display_name: member?.user_display_name ?? null,
author_avatar_url: member?.user_avatar_url ?? null,
});
});
});
},
[isRunning, membersData]
);
useMessagesElectric(threadId, handleElectricMessagesUpdate);
// Create the attachment adapter for file processing
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
@ -587,8 +637,6 @@ export default function NewChatPage() {
content: persistContent,
})
.then(() => {
// For new threads, the backend updates the title from the first user message
// Invalidate threads query so sidebar shows the updated title in real-time
if (isNewThread) {
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
}

View file

@ -0,0 +1,15 @@
"use client";
import { atom } from "jotai";
export interface ChatSessionStateData {
threadId: number;
isAiResponding: boolean;
respondingToUserId: string | null;
}
/**
* Global chat session state atom.
* Updated by useChatSessionStateSync hook, read anywhere.
*/
export const chatSessionStateAtom = atom<ChatSessionStateData | null>(null);

View file

@ -9,7 +9,7 @@ export const membersAtom = atomWithQuery((get) => {
return {
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
queryFn: async () => {
if (!searchSpaceId) {
return [];

View file

@ -0,0 +1,50 @@
"use client";
import { Loader2 } from "lucide-react";
import type { FC } from "react";
import { cn } from "@/lib/utils";
interface ChatSessionStatusProps {
isAiResponding: boolean;
respondingToUserId: string | null;
currentUserId: string | null;
members: Array<{
user_id: string;
user_display_name?: string | null;
user_email?: string | null;
}>;
className?: string;
}
export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
isAiResponding,
respondingToUserId,
currentUserId,
members,
className,
}) => {
if (!isAiResponding || !respondingToUserId) {
return null;
}
if (respondingToUserId === currentUserId) {
return null;
}
const respondingUser = members.find((m) => m.user_id === respondingToUserId);
const displayName =
respondingUser?.user_display_name || respondingUser?.user_email || "another user";
return (
<div
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground bg-muted/50 rounded-lg",
"animate-in fade-in slide-in-from-bottom-2 duration-300 ease-out",
className
)}
>
<Loader2 className="size-3.5 animate-spin" />
<span>Currently responding to {displayName}</span>
</div>
);
};

View file

@ -0,0 +1,464 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { FolderOpen, Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const obsidianConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
vault_path: z.string().min(1, {
message: "Vault path is required.",
}),
vault_name: z.string().min(1, {
message: "Vault name is required.",
}),
exclude_folders: z.string().optional(),
include_attachments: z.boolean(),
});
type ObsidianConnectorFormValues = z.infer<typeof obsidianConnectorFormSchema>;
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [periodicEnabled, setPeriodicEnabled] = useState(true);
const [frequencyMinutes, setFrequencyMinutes] = useState("60");
const form = useForm<ObsidianConnectorFormValues>({
resolver: zodResolver(obsidianConnectorFormSchema),
defaultValues: {
name: "Obsidian Vault",
vault_path: "",
vault_name: "",
exclude_folders: ".obsidian,.trash",
include_attachments: false,
},
});
const handleSubmit = async (values: ObsidianConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
// Parse exclude_folders into an array
const excludeFolders = values.exclude_folders
? values.exclude_folders
.split(",")
.map((f) => f.trim())
.filter(Boolean)
: [".obsidian", ".trash"];
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.OBSIDIAN_CONNECTOR,
config: {
vault_path: values.vault_path,
vault_name: values.vault_name,
exclude_folders: excludeFolders,
include_attachments: values.include_attachments,
},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? Number.parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
return (
<div className="space-y-6 pb-6">
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
This connector requires direct file system access and only works with self-hosted
SurfSense installations.
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="obsidian-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Obsidian Vault"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vault_path"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Vault Path</FormLabel>
<FormControl>
<Input
placeholder="/path/to/your/obsidian/vault"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
The absolute path to your Obsidian vault on the server. This must be accessible
from the SurfSense backend.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vault_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Vault Name</FormLabel>
<FormControl>
<Input
placeholder="My Knowledge Base"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A display name for your vault. This will be used in search results.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exclude_folders"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Exclude Folders</FormLabel>
<FormControl>
<Input
placeholder=".obsidian,.trash,templates"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated list of folder names to exclude from indexing.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="include_attachments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-slate-400/20 p-3">
<div className="space-y-0.5">
<FormLabel className="text-xs sm:text-sm">Include Attachments</FormLabel>
<FormDescription className="text-[10px] sm:text-xs">
Index attachment folders and embedded files (images, PDFs, etc.)
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-100">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">
What you get with Obsidian integration:
</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Obsidian connector scans your local Obsidian vault directory and indexes all
Markdown files. It preserves your note structure and extracts metadata from YAML
frontmatter.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
The connector parses frontmatter metadata (title, tags, aliases, dates, etc.)
</li>
<li>Wiki-style links ([[note]]) are extracted and preserved</li>
<li>Inline tags (#tag) are recognized and indexed</li>
<li>Content is chunked intelligently for optimal search results</li>
<li>
Subsequent indexing runs use content hashing to skip unchanged files for faster
sync
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Setup</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
File System Access Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
The SurfSense backend must have read access to your Obsidian vault directory.
For Docker deployments, mount your vault as a volume.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Locate your vault
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>macOS/Linux:</strong> Right-click any note in Obsidian "Reveal in
Finder" to see the vault folder
</li>
<li>
<strong>Windows:</strong> Right-click any note "Show in system explorer"
</li>
<li>
<strong>Or:</strong> Click the vault switcher (bottom-left icon) "Open
folder" next to your vault name
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Enter the path
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
<strong>Running locally (no Docker):</strong> Use the direct path to your
vault:
</p>
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto mb-2">
{`/Users/yourname/Documents/MyObsidianVault`}
</pre>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
<strong>Running in Docker:</strong> Mount your vault as a volume in
docker-compose.yml:
</p>
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto">
{`volumes:
- /path/to/your/vault:/app/obsidian_vaults/my-vault:ro`}
</pre>
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
Then use <code>/app/obsidian_vaults/my-vault</code> as your vault path.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Configure exclusions
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Common folders to exclude:
</p>
<ul className="list-disc pl-5 mt-1 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
<code>.obsidian</code> - Obsidian config (always recommended)
</li>
<li>
<code>.trash</code> - Obsidian's trash folder
</li>
<li>
<code>templates</code> - If you have a templates folder
</li>
<li>
<code>daily-notes</code> - If you want to exclude daily notes
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">What Gets Indexed</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Indexed Content</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Obsidian connector indexes:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All Markdown files (.md) in your vault</li>
<li>YAML frontmatter metadata (title, tags, aliases, dates)</li>
<li>Wiki-style links between notes</li>
<li>Inline tags throughout your notes</li>
<li>Full note content with proper chunking</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -108,6 +108,14 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
"Real-time updates via webhook integration",
"No manual indexing required - meetings are added automatically",
],
OBSIDIAN_CONNECTOR: [
"Search through all your Obsidian notes and knowledge base",
"Access note content with YAML frontmatter metadata preserved",
"Wiki-style links ([[note]]) and #tags are indexed",
"Connect your personal knowledge base directly to your search space",
"Incremental sync - only changed files are re-indexed",
"Full support for your vault's folder structure",
],
};
return benefits[connectorType] || null;

View file

@ -7,6 +7,7 @@ import { GithubConnectForm } from "./components/github-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form";
import { MCPConnectForm } from "./components/mcp-connect-form";
import { ObsidianConnectForm } from "./components/obsidian-connect-form";
import { SearxngConnectForm } from "./components/searxng-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
@ -58,6 +59,8 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
return CirclebackConnectForm;
case "MCP_CONNECTOR":
return MCPConnectForm;
case "OBSIDIAN_CONNECTOR":
return ObsidianConnectForm;
// Add other connector types here as needed
default:
return null;

View file

@ -0,0 +1,191 @@
"use client";
import { FolderOpen } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import type { ConnectorConfigProps } from "../index";
export interface ObsidianConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const ObsidianConfig: FC<ObsidianConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [vaultPath, setVaultPath] = useState<string>(
(connector.config?.vault_path as string) || ""
);
const [vaultName, setVaultName] = useState<string>(
(connector.config?.vault_name as string) || ""
);
const [excludeFolders, setExcludeFolders] = useState<string>(() => {
const folders = connector.config?.exclude_folders;
if (Array.isArray(folders)) {
return folders.join(", ");
}
return (folders as string) || ".obsidian, .trash";
});
const [includeAttachments, setIncludeAttachments] = useState<boolean>(
(connector.config?.include_attachments as boolean) || false
);
const [name, setName] = useState<string>(connector.name || "");
// Update values when connector changes
useEffect(() => {
const path = (connector.config?.vault_path as string) || "";
const vName = (connector.config?.vault_name as string) || "";
const folders = connector.config?.exclude_folders;
const attachments = (connector.config?.include_attachments as boolean) || false;
setVaultPath(path);
setVaultName(vName);
setIncludeAttachments(attachments);
setName(connector.name || "");
if (Array.isArray(folders)) {
setExcludeFolders(folders.join(", "));
} else if (typeof folders === "string") {
setExcludeFolders(folders);
}
}, [connector.config, connector.name]);
const handleVaultPathChange = (value: string) => {
setVaultPath(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
vault_path: value,
});
}
};
const handleVaultNameChange = (value: string) => {
setVaultName(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
vault_name: value,
});
}
};
const handleExcludeFoldersChange = (value: string) => {
setExcludeFolders(value);
const foldersArray = value
.split(",")
.map((f) => f.trim())
.filter(Boolean);
if (onConfigChange) {
onConfigChange({
...connector.config,
exclude_folders: foldersArray,
});
}
};
const handleIncludeAttachmentsChange = (value: boolean) => {
setIncludeAttachments(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
include_attachments: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Obsidian Vault"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-purple-500" />
Vault Configuration
</h3>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Vault Path</Label>
<Input
value={vaultPath}
onChange={(e) => handleVaultPathChange(e.target.value)}
placeholder="/path/to/your/obsidian/vault"
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The absolute path to your Obsidian vault on the server.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Vault Name</Label>
<Input
value={vaultName}
onChange={(e) => handleVaultNameChange(e.target.value)}
placeholder="My Knowledge Base"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A display name for your vault in search results.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Exclude Folders</Label>
<Input
value={excludeFolders}
onChange={(e) => handleExcludeFoldersChange(e.target.value)}
placeholder=".obsidian, .trash, templates"
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Comma-separated list of folder names to exclude from indexing.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-400/20 p-3">
<div className="space-y-0.5">
<Label className="text-xs sm:text-sm">Include Attachments</Label>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Index attachment folders and embedded files
</p>
</div>
<Switch
checked={includeAttachments}
onCheckedChange={handleIncludeAttachmentsChange}
/>
</div>
</div>
</div>
</div>
);
};

View file

@ -5,8 +5,8 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
import { BookStackConfig } from "./components/bookstack-config";
import { CirclebackConfig } from "./components/circleback-config";
import { ComposioConfig } from "./components/composio-config";
import { ClickUpConfig } from "./components/clickup-config";
import { ComposioConfig } from "./components/composio-config";
import { ConfluenceConfig } from "./components/confluence-config";
import { DiscordConfig } from "./components/discord-config";
import { ElasticsearchConfig } from "./components/elasticsearch-config";
@ -16,6 +16,7 @@ import { JiraConfig } from "./components/jira-config";
import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config";
import { ObsidianConfig } from "./components/obsidian-config";
import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
@ -74,6 +75,8 @@ export function getConnectorConfigComponent(
return CirclebackConfig;
case "MCP_CONNECTOR":
return MCPConfig;
case "OBSIDIAN_CONNECTOR":
return ObsidianConfig;
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
case "COMPOSIO_GMAIL_CONNECTOR":
case "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":

View file

@ -57,6 +57,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
LUMA_CONNECTOR: "luma-connect-form",
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
MCP_CONNECTOR: "mcp-connect-form",
OBSIDIAN_CONNECTOR: "obsidian-connect-form",
};
const formId = formIdMap[connectorType];
if (formId) {
@ -141,12 +142,10 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting
</>
) : connectorType === "MCP_CONNECTOR" ? (
"Connect"
) : (
<>
{connectorType === "MCP_CONNECTOR"
? "Connect"
: `Connect ${getConnectorTypeDisplay(connectorType)}`}
</>
`Connect ${getConnectorTypeDisplay(connectorType)}`
)}
</Button>
</div>

View file

@ -166,6 +166,13 @@ export const OTHER_CONNECTORS = [
description: "Connect to MCP servers for AI tools",
connectorType: EnumConnectorName.MCP_CONNECTOR,
},
{
id: "obsidian-connector",
title: "Obsidian",
description: "Index your Obsidian vault (self-hosted only)",
connectorType: EnumConnectorName.OBSIDIAN_CONNECTOR,
selfHostedOnly: true,
},
] as const;
// Composio Connectors - Individual entries for each supported toolkit

View file

@ -3,12 +3,13 @@
import type { FC } from "react";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { isSelfHosted } from "@/lib/env-config";
import { ConnectorCard } from "../components/connector-card";
import {
COMPOSIO_CONNECTORS,
CRAWLERS,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
COMPOSIO_CONNECTORS,
} from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -57,23 +58,31 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
// Filter connectors based on search
// Check if self-hosted mode (for showing self-hosted only connectors)
const selfHosted = isSelfHosted();
// Filter connectors based on search and deployment mode
const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) =>
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
// Filter by search query
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
// Filter self-hosted only connectors in cloud mode
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
);
const filteredCrawlers = CRAWLERS.filter(
(c) =>
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
);
const filteredOther = OTHER_CONNECTORS.filter(
(c) =>
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
(!("selfHostedOnly" in c) || !c.selfHostedOnly || selfHosted)
);
// Filter Composio connectors

View file

@ -26,6 +26,7 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
ELASTICSEARCH_CONNECTOR: "ELASTICSEARCH_CONNECTOR",
BOOKSTACK_CONNECTOR: "BOOKSTACK_CONNECTOR",
CIRCLEBACK_CONNECTOR: "CIRCLEBACK",
OBSIDIAN_CONNECTOR: "OBSIDIAN_CONNECTOR",
// Special mappings (connector type differs from document type)
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",

View file

@ -26,11 +26,13 @@ import {
import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
@ -39,6 +41,7 @@ import {
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import {
InlineMentionEditor,
@ -59,6 +62,7 @@ import {
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Button } from "@/components/ui/button";
import type { Document } from "@/contracts/types/document.types";
import { useCommentsElectric } from "@/hooks/use-comments-electric";
import { cn } from "@/lib/utils";
interface ThreadProps {
@ -86,6 +90,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
>
<ThreadPrimitive.Viewport
turnAnchor="top"
autoScroll
className={cn(
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
showGutter && "lg:pr-30"
@ -215,7 +220,7 @@ const Composer: FC = () => {
const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id } = useParams();
const { search_space_id, chat_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false);
@ -223,6 +228,23 @@ const Composer: FC = () => {
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Live collaboration state
const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: members } = useAtomValue(membersAtom);
const threadId = useMemo(() => {
if (Array.isArray(chat_id) && chat_id.length > 0) {
return Number.parseInt(chat_id[0], 10) || null;
}
return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null;
}, [chat_id]);
const sessionState = useAtomValue(chatSessionStateAtom);
const isAiResponding = sessionState?.isAiResponding ?? false;
const respondingToUserId = sessionState?.respondingToUserId ?? null;
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
// Sync comments for the entire thread via Electric SQL (one subscription per thread)
useCommentsElectric(threadId);
// Auto-focus editor on new chat page after mount
useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
@ -298,9 +320,9 @@ const Composer: FC = () => {
[showDocumentPopover]
);
// Submit message (blocked during streaming or when document picker is open)
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => {
if (isThreadRunning) {
if (isThreadRunning || isBlockedByOtherUser) {
return;
}
if (!showDocumentPopover) {
@ -315,6 +337,7 @@ const Composer: FC = () => {
}, [
showDocumentPopover,
isThreadRunning,
isBlockedByOtherUser,
composerRuntime,
setMentionedDocuments,
setMentionedDocumentIds,
@ -374,7 +397,13 @@ const Composer: FC = () => {
);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ChatSessionStatus
isAiResponding={isAiResponding}
respondingToUserId={respondingToUserId}
currentUserId={currentUser?.id ?? null}
members={members ?? []}
/>
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
{/* Inline editor with @mention support */}
@ -417,13 +446,17 @@ const Composer: FC = () => {
/>,
document.body
)}
<ComposerAction />
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};
const ComposerAction: FC = () => {
interface ComposerActionProps {
isBlockedByOtherUser?: boolean;
}
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
// Check if any attachments are still being processed (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const hasProcessingAttachments = useAssistantState(({ composer }) =>
@ -458,7 +491,8 @@ const ComposerAction: FC = () => {
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
}, [preferences, globalConfigs, userConfigs]);
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
const isSendDisabled =
hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
@ -487,13 +521,15 @@ const ComposerAction: FC = () => {
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
!hasModelConfigured
? "Please select a model from the header to start chatting"
: hasProcessingAttachments
? "Wait for attachments to process"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
isBlockedByOtherUser
? "Wait for AI to finish responding"
: !hasModelConfigured
? "Please select a model from the header to start chatting"
: hasProcessingAttachments
? "Wait for attachments to process"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"

View file

@ -179,7 +179,7 @@ export function DashboardBreadcrumb() {
const breadcrumbs = generateBreadcrumbs(pathname);
if (breadcrumbs.length <= 1) {
if (breadcrumbs.length === 0) {
return null; // Don't show breadcrumbs for root dashboard
}

View file

@ -167,12 +167,6 @@ export function LayoutDataProvider({
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
// {
// title: "Logs",
// url: `/dashboard/${searchSpaceId}/logs`,
// icon: Logs,
// isActive: pathname?.includes("/logs"),
// },
{
title: "Inbox",
url: "#inbox", // Special URL to indicate this is handled differently

View file

@ -41,14 +41,14 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
type ConnectorIndexingMetadata,
type NewMentionMetadata,
isConnectorIndexingMetadata,
isNewMentionMetadata,
type NewMentionMetadata,
} from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
/**

View file

@ -119,11 +119,6 @@ export function Sidebar({
)}
</div>
{/* Platform navigation */}
{navItems.length > 0 && (
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
)}
{/* Scrollable content */}
<ScrollArea className="flex-1">
{isCollapsed ? (
@ -235,7 +230,12 @@ export function Sidebar({
</ScrollArea>
{/* Footer */}
<div className="mt-auto">
<div className="mt-auto border-t">
{/* Platform navigation */}
{navItems.length > 0 && (
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
)}
{pageUsage && !isCollapsed && (
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}

View file

@ -1,6 +1,7 @@
"use client";
import { ChevronsUpDown, Settings, Users } from "lucide-react";
import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@ -29,6 +30,9 @@ export function SidebarHeader({
className,
}: SidebarHeaderProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
return (
<div className={cn("flex shrink-0 items-center", className)}>
@ -52,6 +56,10 @@ export function SidebarHeader({
<Users className="mr-2 h-4 w-4" />
{t("manage_members")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
<ScrollText className="mr-2 h-4 w-4" />
{t("logs")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronUp, Laptop, Languages, LogOut, Moon, Settings, Sun } from "lucide-react";
import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,

View file

@ -123,6 +123,11 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
);
}, [userConfigs, searchQuery]);
// Total model count for conditional search display
const totalModels = useMemo(() => {
return (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0);
}, [globalConfigs, userConfigs]);
const handleSelectConfig = useCallback(
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
// If already selected, just close
@ -212,14 +217,16 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
shouldFilter={false}
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
>
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
<CommandInput
placeholder="Search models"
value={searchQuery}
onValueChange={setSearchQuery}
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
/>
</div>
{totalModels > 3 && (
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
<CommandInput
placeholder="Search models"
value={searchQuery}
onValueChange={setSearchQuery}
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
/>
</div>
)}
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
<CommandEmpty className="py-8 text-center">
@ -245,7 +252,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`global-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
@ -276,7 +283,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted"
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleEditConfig(e, config, true)}
>
<Edit3 className="size-3.5 text-muted-foreground" />
@ -307,7 +314,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`user-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
@ -338,7 +345,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md hover:bg-muted"
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleEditConfig(e, config, false)}
>
<Edit3 className="size-3.5 text-muted-foreground" />

View file

@ -1,6 +1,6 @@
"use client";
import * as React from "react";
import type * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";

View file

@ -23,6 +23,7 @@ export enum EnumConnectorName {
WEBCRAWLER_CONNECTOR = "WEBCRAWLER_CONNECTOR",
YOUTUBE_CONNECTOR = "YOUTUBE_CONNECTOR",
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR",
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR",
MCP_CONNECTOR = "MCP_CONNECTOR",
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
COMPOSIO_GMAIL_CONNECTOR = "COMPOSIO_GMAIL_CONNECTOR",

View file

@ -66,6 +66,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <IconUsersGroup {...iconProps} />;
case EnumConnectorName.MCP_CONNECTOR:
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />;
case EnumConnectorName.OBSIDIAN_CONNECTOR:
return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />;
case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
return <Image src="/connectors/google-drive.svg" alt="Google Drive" {...imgProps} />;
case EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR:

View file

@ -1,5 +1,19 @@
import { z } from "zod";
/**
* Raw comment
*/
export const rawComment = z.object({
id: z.number(),
message_id: z.number(),
thread_id: z.number(), // Denormalized for efficient Electric subscriptions
parent_id: z.number().nullable(),
author_id: z.string().nullable(),
content: z.string(),
created_at: z.string(),
updated_at: z.string(),
});
export const author = z.object({
id: z.string().uuid(),
display_name: z.string().nullable(),
@ -122,6 +136,7 @@ export const getMentionsResponse = z.object({
total_count: z.number(),
});
export type RawComment = z.infer<typeof rawComment>;
export type Author = z.infer<typeof author>;
export type CommentReply = z.infer<typeof commentReply>;
export type Comment = z.infer<typeof comment>;

View file

@ -0,0 +1,15 @@
import { z } from "zod";
/**
* Raw message from database (Electric SQL sync)
*/
export const rawMessage = z.object({
id: z.number(),
thread_id: z.number(),
role: z.string(),
content: z.unknown(),
author_id: z.string().nullable(),
created_at: z.string(),
});
export type RawMessage = z.infer<typeof rawMessage>;

View file

@ -0,0 +1,24 @@
import { z } from "zod";
/**
* Chat session state for live collaboration.
* Tracks which user the AI is currently responding to.
*/
export const chatSessionState = z.object({
id: z.number(),
thread_id: z.number(),
ai_responding_to_user_id: z.string().uuid().nullable(),
updated_at: z.string(),
});
/**
* User currently being responded to by the AI.
*/
export const respondingUser = z.object({
id: z.string().uuid(),
display_name: z.string().nullable(),
email: z.string(),
});
export type ChatSessionState = z.infer<typeof chatSessionState>;
export type RespondingUser = z.infer<typeof respondingUser>;

View file

@ -27,6 +27,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
"BOOKSTACK_CONNECTOR",
"CIRCLEBACK_CONNECTOR",
"MCP_CONNECTOR",
"OBSIDIAN_CONNECTOR",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
"COMPOSIO_GMAIL_CONNECTOR",
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",

View file

@ -0,0 +1,39 @@
"use client";
import { useShape } from "@electric-sql/react";
import { useSetAtom } from "jotai";
import { useEffect } from "react";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import type { ChatSessionState } from "@/contracts/types/chat-session-state.types";
const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133";
/**
* Syncs chat session state for a thread via Electric SQL.
* Call once per thread (in page.tsx). Updates global atom.
*/
export function useChatSessionStateSync(threadId: number | null) {
const setSessionState = useSetAtom(chatSessionStateAtom);
const { data } = useShape<ChatSessionState>({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: "chat_session_state",
where: `thread_id = ${threadId ?? -1}`,
},
});
useEffect(() => {
if (!threadId) {
setSessionState(null);
return;
}
const row = data?.[0];
setSessionState({
threadId,
isAiResponding: !!row?.ai_responding_to_user_id,
respondingToUserId: row?.ai_responding_to_user_id ?? null,
});
}, [threadId, data, setSessionState]);
}

View file

@ -0,0 +1,405 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import type { Author, Comment, CommentReply } from "@/contracts/types/chat-comments.types";
import type { Membership } from "@/contracts/types/members.types";
import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context";
import { cacheKeys } from "@/lib/query-client/cache-keys";
// Debounce delay for stream updates (ms)
const STREAM_UPDATE_DEBOUNCE_MS = 100;
// Raw comment from PGlite local database
interface RawCommentRow {
id: number;
message_id: number;
thread_id: number;
parent_id: number | null;
author_id: string | null;
content: string;
created_at: string;
updated_at: string;
}
// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN)
const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g;
type MemberInfo = Pick<Membership, "user_display_name" | "user_avatar_url" | "user_email">;
/**
* Render mentions in content by replacing @[uuid] with @{DisplayName}
*/
function renderMentions(content: string, memberMap: Map<string, MemberInfo>): string {
return content.replace(MENTION_PATTERN, (match, uuid) => {
const member = memberMap.get(uuid);
if (member?.user_display_name) {
return `@{${member.user_display_name}}`;
}
return match;
});
}
/**
* Build member lookup map from membersData
*/
function buildMemberMap(membersData: Membership[] | undefined): Map<string, MemberInfo> {
const map = new Map<string, MemberInfo>();
if (membersData) {
for (const m of membersData) {
map.set(m.user_id, {
user_display_name: m.user_display_name,
user_avatar_url: m.user_avatar_url,
user_email: m.user_email,
});
}
}
return map;
}
/**
* Build author object from member data
*/
function buildAuthor(authorId: string | null, memberMap: Map<string, MemberInfo>): Author | null {
if (!authorId) return null;
const m = memberMap.get(authorId);
if (!m) return null;
return {
id: authorId,
display_name: m.user_display_name ?? null,
avatar_url: m.user_avatar_url ?? null,
email: m.user_email ?? "",
};
}
/**
* Check if a comment has been edited by comparing timestamps.
* Uses a small threshold to handle precision differences.
*/
function isEdited(createdAt: string, updatedAt: string): boolean {
const created = new Date(createdAt).getTime();
const updated = new Date(updatedAt).getTime();
// Consider edited if updated_at is more than 1 second after created_at
return updated - created > 1000;
}
/**
* Transform raw comment to CommentReply
*/
function transformReply(
raw: RawCommentRow,
memberMap: Map<string, MemberInfo>,
currentUserId: string | undefined,
isOwner: boolean
): CommentReply {
return {
id: raw.id,
content: raw.content,
content_rendered: renderMentions(raw.content, memberMap),
author: buildAuthor(raw.author_id, memberMap),
created_at: raw.created_at,
updated_at: raw.updated_at,
is_edited: isEdited(raw.created_at, raw.updated_at),
can_edit: currentUserId === raw.author_id,
can_delete: currentUserId === raw.author_id || isOwner,
};
}
/**
* Transform raw comments to Comment with replies
*/
function transformComments(
rawComments: RawCommentRow[],
memberMap: Map<string, MemberInfo>,
currentUserId: string | undefined,
isOwner: boolean
): Map<number, Comment[]> {
// Group comments by message_id
const byMessage = new Map<
number,
{ topLevel: RawCommentRow[]; replies: Map<number, RawCommentRow[]> }
>();
for (const raw of rawComments) {
if (!byMessage.has(raw.message_id)) {
byMessage.set(raw.message_id, { topLevel: [], replies: new Map() });
}
const group = byMessage.get(raw.message_id)!;
if (raw.parent_id === null) {
group.topLevel.push(raw);
} else {
if (!group.replies.has(raw.parent_id)) {
group.replies.set(raw.parent_id, []);
}
group.replies.get(raw.parent_id)!.push(raw);
}
}
// Transform to Comment objects grouped by message_id
const result = new Map<number, Comment[]>();
for (const [messageId, group] of byMessage) {
const comments: Comment[] = group.topLevel.map((raw) => {
const replies = (group.replies.get(raw.id) || [])
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map((r) => transformReply(r, memberMap, currentUserId, isOwner));
return {
id: raw.id,
message_id: raw.message_id,
content: raw.content,
content_rendered: renderMentions(raw.content, memberMap),
author: buildAuthor(raw.author_id, memberMap),
created_at: raw.created_at,
updated_at: raw.updated_at,
is_edited: isEdited(raw.created_at, raw.updated_at),
can_edit: currentUserId === raw.author_id,
can_delete: currentUserId === raw.author_id || isOwner,
reply_count: replies.length,
replies,
};
});
// Sort by created_at
comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
result.set(messageId, comments);
}
return result;
}
/**
* Hook for syncing comments with Electric SQL real-time sync.
*
* Syncs ALL comments for a thread in ONE subscription, then updates
* React Query cache for each message. This avoids N subscriptions for N messages.
*
* @param threadId - The thread ID to sync comments for
*/
export function useCommentsElectric(threadId: number | null) {
const electricClient = useElectricClient();
const queryClient = useQueryClient();
const { data: membersData } = useAtomValue(membersAtom);
const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: myAccess } = useAtomValue(myAccessAtom);
const memberMap = useMemo(() => buildMemberMap(membersData), [membersData]);
const currentUserId = currentUser?.id;
const isOwner = myAccess?.is_owner ?? false;
// Use refs for values needed in live query callback to avoid stale closures
const memberMapRef = useRef(memberMap);
const currentUserIdRef = useRef(currentUserId);
const isOwnerRef = useRef(isOwner);
const queryClientRef = useRef(queryClient);
// Keep refs updated
useEffect(() => {
memberMapRef.current = memberMap;
currentUserIdRef.current = currentUserId;
isOwnerRef.current = isOwner;
queryClientRef.current = queryClient;
}, [memberMap, currentUserId, isOwner, queryClient]);
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const syncKeyRef = useRef<string | null>(null);
const streamUpdateDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable callback that uses refs for fresh values
const updateReactQueryCache = useCallback((rows: RawCommentRow[]) => {
const commentsByMessage = transformComments(
rows,
memberMapRef.current,
currentUserIdRef.current,
isOwnerRef.current
);
for (const [messageId, comments] of commentsByMessage) {
const cacheKey = cacheKeys.comments.byMessage(messageId);
queryClientRef.current.setQueryData(cacheKey, {
comments,
total_count: comments.length,
});
}
}, []);
useEffect(() => {
if (!threadId || !electricClient) {
return;
}
const syncKey = `comments_${threadId}`;
if (syncKeyRef.current === syncKey) {
return;
}
// Capture in local variable for use in async functions
const client = electricClient;
let mounted = true;
syncKeyRef.current = syncKey;
async function startSync() {
try {
const handle = await client.syncShape({
table: "chat_comments",
where: `thread_id = ${threadId}`,
columns: [
"id",
"message_id",
"thread_id",
"parent_id",
"author_id",
"content",
"created_at",
"updated_at",
],
primaryKey: ["id"],
});
if (!handle.isUpToDate && handle.initialSyncPromise) {
try {
await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
} catch {
// Initial sync timeout - continue anyway
}
}
if (!mounted) {
handle.unsubscribe();
return;
}
syncHandleRef.current = handle;
// Fetch initial comments and update cache
await fetchAndUpdateCache();
// Set up live query for real-time updates
await setupLiveQuery();
// Subscribe to the sync stream for real-time updates from Electric SQL
// This ensures we catch updates even if PGlite live query misses them
if (handle.stream) {
const stream = handle.stream as {
subscribe?: (callback: (messages: unknown[]) => void) => void;
};
if (typeof stream.subscribe === "function") {
stream.subscribe((messages: unknown[]) => {
if (!mounted) return;
// When Electric sync receives new data, refresh from PGlite
// This handles cases where live query might miss the update
if (messages && messages.length > 0) {
// Debounce the refresh to avoid excessive queries
if (streamUpdateDebounceRef.current) {
clearTimeout(streamUpdateDebounceRef.current);
}
streamUpdateDebounceRef.current = setTimeout(() => {
if (mounted) {
fetchAndUpdateCache();
}
}, STREAM_UPDATE_DEBOUNCE_MS);
}
});
}
}
} catch {
// Sync failed - will retry on next mount
}
}
async function fetchAndUpdateCache() {
try {
const result = await client.db.query<RawCommentRow>(
`SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at
FROM chat_comments
WHERE thread_id = $1
ORDER BY created_at ASC`,
[threadId]
);
if (mounted && result.rows) {
updateReactQueryCache(result.rows);
}
} catch {
// Query failed - data will be fetched from API
}
}
async function setupLiveQuery() {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = client.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(
`SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at
FROM chat_comments
WHERE thread_id = $1
ORDER BY created_at ASC`,
[threadId]
);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
// Set initial results
if (liveQuery.initialResults?.rows) {
updateReactQueryCache(liveQuery.initialResults.rows);
} else if (liveQuery.rows) {
updateReactQueryCache(liveQuery.rows);
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: RawCommentRow[] }) => {
if (mounted && result.rows) {
updateReactQueryCache(result.rows);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
liveQueryRef.current = liveQuery;
}
}
} catch {
// Live query setup failed - will use initial fetch only
}
}
startSync();
return () => {
mounted = false;
syncKeyRef.current = null;
// Clear debounce timeout
if (streamUpdateDebounceRef.current) {
clearTimeout(streamUpdateDebounceRef.current);
streamUpdateDebounceRef.current = null;
}
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
};
}, [threadId, electricClient, updateReactQueryCache]);
}

View file

@ -0,0 +1,154 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import type { RawMessage } from "@/contracts/types/chat-messages.types";
import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context";
/**
* Syncs chat messages for a thread via Electric SQL.
* Calls onMessagesUpdate when messages change.
*/
export function useMessagesElectric(
threadId: number | null,
onMessagesUpdate: (messages: RawMessage[]) => void
) {
const electricClient = useElectricClient();
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const syncKeyRef = useRef<string | null>(null);
const onMessagesUpdateRef = useRef(onMessagesUpdate);
useEffect(() => {
onMessagesUpdateRef.current = onMessagesUpdate;
}, [onMessagesUpdate]);
const handleMessagesUpdate = useCallback((rows: RawMessage[]) => {
onMessagesUpdateRef.current(rows);
}, []);
useEffect(() => {
if (!threadId || !electricClient) {
return;
}
const syncKey = `messages_${threadId}`;
if (syncKeyRef.current === syncKey) {
return;
}
const client = electricClient;
let mounted = true;
syncKeyRef.current = syncKey;
async function startSync() {
try {
const handle = await client.syncShape({
table: "new_chat_messages",
where: `thread_id = ${threadId}`,
columns: ["id", "thread_id", "role", "content", "author_id", "created_at"],
primaryKey: ["id"],
});
if (!handle.isUpToDate && handle.initialSyncPromise) {
try {
await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
} catch {
// Timeout
}
}
if (!mounted) {
handle.unsubscribe();
return;
}
syncHandleRef.current = handle;
await fetchMessages();
await setupLiveQuery();
} catch {
// Sync failed
}
}
async function fetchMessages() {
try {
const result = await client.db.query<RawMessage>(
`SELECT id, thread_id, role, content, author_id, created_at
FROM new_chat_messages
WHERE thread_id = $1
ORDER BY created_at ASC`,
[threadId]
);
if (mounted && result.rows) {
handleMessagesUpdate(result.rows);
}
} catch {
// Query failed
}
}
async function setupLiveQuery() {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = client.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(
`SELECT id, thread_id, role, content, author_id, created_at
FROM new_chat_messages
WHERE thread_id = $1
ORDER BY created_at ASC`,
[threadId]
);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
if (liveQuery.initialResults?.rows) {
handleMessagesUpdate(liveQuery.initialResults.rows);
} else if (liveQuery.rows) {
handleMessagesUpdate(liveQuery.rows);
}
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: RawMessage[] }) => {
if (mounted && result.rows) {
handleMessagesUpdate(result.rows);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
liveQueryRef.current = liveQuery;
}
}
} catch {
// Live query failed
}
}
startSync();
return () => {
mounted = false;
syncKeyRef.current = null;
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
}
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
};
}, [threadId, electricClient, handleMessagesUpdate]);
}

View file

@ -2,12 +2,12 @@ import {
type GetNotificationsRequest,
type GetNotificationsResponse,
type GetUnreadCountResponse,
type MarkAllNotificationsReadResponse,
type MarkNotificationReadRequest,
type MarkNotificationReadResponse,
getNotificationsRequest,
getNotificationsResponse,
getUnreadCountResponse,
type MarkAllNotificationsReadResponse,
type MarkNotificationReadRequest,
type MarkNotificationReadResponse,
markAllNotificationsReadResponse,
markNotificationReadRequest,
markNotificationReadResponse,

View file

@ -229,7 +229,6 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
`);
// Create the chat_comment_mentions table schema in PGlite
await db.exec(`
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
id INTEGER PRIMARY KEY,
@ -242,6 +241,39 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id);
`);
// Create chat_comments table for live comment sync
await db.exec(`
CREATE TABLE IF NOT EXISTS chat_comments (
id INTEGER PRIMARY KEY,
message_id INTEGER NOT NULL,
thread_id INTEGER NOT NULL,
parent_id INTEGER,
author_id TEXT,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id);
CREATE INDEX IF NOT EXISTS idx_chat_comments_message_id ON chat_comments(message_id);
CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id);
`);
// Create new_chat_messages table for live message sync
await db.exec(`
CREATE TABLE IF NOT EXISTS new_chat_messages (
id INTEGER PRIMARY KEY,
thread_id INTEGER NOT NULL,
role TEXT NOT NULL,
content JSONB NOT NULL,
author_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_new_chat_messages_thread_id ON new_chat_messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_new_chat_messages_created_at ON new_chat_messages(created_at);
`);
const electricUrl = getElectricUrl();
// STEP 4: Create the client wrapper

View file

@ -21,8 +21,21 @@ export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http:
// Placeholder: __NEXT_PUBLIC_ETL_SERVICE__
export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING";
// Deployment Mode: "self-hosted" or "cloud"
// Matches backend's SURFSENSE_DEPLOYMENT_MODE - defaults to "self-hosted"
// self-hosted: Full access to local file system connectors (Obsidian, etc.)
// cloud: Only cloud-based connectors available
// Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__
export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted";
// Helper to check if local auth is enabled
export const isLocalAuth = () => AUTH_TYPE === "LOCAL";
// Helper to check if Google auth is enabled
export const isGoogleAuth = () => AUTH_TYPE === "GOOGLE";
// Helper to check if running in self-hosted mode
export const isSelfHosted = () => DEPLOYMENT_MODE === "self-hosted";
// Helper to check if running in cloud mode
export const isCloud = () => DEPLOYMENT_MODE === "cloud";

View file

@ -683,6 +683,7 @@
"select_search_space": "Select Search Space",
"manage_members": "Manage members",
"search_space_settings": "Search Space settings",
"logs": "Logs",
"see_all_search_spaces": "See all search spaces",
"expand_sidebar": "Expand sidebar",
"collapse_sidebar": "Collapse sidebar",

View file

@ -668,6 +668,7 @@
"select_search_space": "选择搜索空间",
"manage_members": "管理成员",
"search_space_settings": "搜索空间设置",
"logs": "日志",
"see_all_search_spaces": "查看所有搜索空间",
"expand_sidebar": "展开侧边栏",
"collapse_sidebar": "收起侧边栏",

View file

@ -35,7 +35,7 @@
"@electric-sql/react": "^1.0.26",
"@hookform/resolvers": "^5.2.2",
"@number-flow/react": "^0.5.10",
"@posthog/react": "^1.5.2",
"@posthog/react": "^1.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
@ -86,8 +86,8 @@
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"postgres": "^3.4.7",
"posthog-js": "^1.310.1",
"posthog-node": "^5.18.0",
"posthog-js": "^1.334.1",
"posthog-node": "^5.24.1",
"react": "^19.2.3",
"react-day-picker": "^9.8.1",
"react-dom": "^19.2.3",

View file

@ -51,8 +51,8 @@ importers:
specifier: ^0.5.10
version: 0.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@posthog/react':
specifier: ^1.5.2
version: 1.5.2(@types/react@19.2.7)(posthog-js@1.310.1)(react@19.2.3)
specifier: ^1.7.0
version: 1.7.0(@types/react@19.2.7)(posthog-js@1.334.1)(react@19.2.3)
'@radix-ui/react-accordion':
specifier: ^1.2.11
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -204,11 +204,11 @@ importers:
specifier: ^3.4.7
version: 3.4.7
posthog-js:
specifier: ^1.310.1
version: 1.310.1
specifier: ^1.334.1
version: 1.334.1
posthog-node:
specifier: ^5.18.0
version: 5.18.0
specifier: ^5.24.1
version: 5.24.1
react:
specifier: ^19.2.3
version: 19.2.3
@ -1447,10 +1447,78 @@ packages:
react: ^18 || ^19
react-dom: ^18 || ^19
'@opentelemetry/api-logs@0.208.0':
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/core@2.2.0':
resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.5.0':
resolution: {integrity: sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/exporter-logs-otlp-http@0.208.0':
resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-exporter-base@0.208.0':
resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-transformer@0.208.0':
resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/resources@2.2.0':
resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/resources@2.5.0':
resolution: {integrity: sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-logs@0.208.0':
resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
'@opentelemetry/sdk-metrics@2.2.0':
resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.9.0 <1.10.0'
'@opentelemetry/sdk-trace-base@2.2.0':
resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/semantic-conventions@1.39.0':
resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==}
engines: {node: '>=14'}
'@orama/orama@3.1.18':
resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==}
engines: {node: '>= 20.0.0'}
@ -1537,11 +1605,11 @@ packages:
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@posthog/core@1.9.0':
resolution: {integrity: sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw==}
'@posthog/core@1.13.0':
resolution: {integrity: sha512-knjncrk7qRmssFRbGzBl1Tunt21GRpe0Wv+uVelyL0Rh7PdQUsgguulzXFTps8hA6wPwTU4kq85qnbAJ3eH6Wg==}
'@posthog/react@1.5.2':
resolution: {integrity: sha512-KHdXbV1yba7Y2l8BVmwXlySWxqKVLNQ5ZiVvWOf7r3Eo7GIFxCM4CaNK/z83kKWn8KTskmKy7AGF6Hl6INWK3g==}
'@posthog/react@1.7.0':
resolution: {integrity: sha512-pM7GL7z/rKjiIwosbRiQA3buhLI6vUo+wg+T/ZrVZC7O5bVU07TfgNZTcuOj8E9dx7vDbfNrc1kjDN7PKMM8ug==}
peerDependencies:
'@types/react': '>=16.8.0'
posthog-js: '>=1.257.2'
@ -1550,6 +1618,9 @@ packages:
'@types/react':
optional: true
'@posthog/types@1.334.1':
resolution: {integrity: sha512-ypFnwTO7qbV7icylLbujbamPdQXbJq0a61GUUBnJAeTbBw/qYPIss5IRYICcbCj0uunQrwD7/CGxVb5TOYKWgA==}
'@prisma/client@4.8.1':
resolution: {integrity: sha512-d4xhZhETmeXK/yZ7K0KcVOzEfI5YKGGEr4F5SBV04/MU4ncN/HcE28sy3e4Yt8UFW0ZuImKFQJE+9rWt9WbGSQ==}
engines: {node: '>=14.17'}
@ -1562,6 +1633,36 @@ packages:
'@prisma/engines-version@4.8.0-61.d6e67a83f971b175a593ccc12e15c4a757f93ffe':
resolution: {integrity: sha512-MHSOSexomRMom8QN4t7bu87wPPD+pa+hW9+71JnVcF3DqyyO/ycCLhRL1we3EojRpZxKvuyGho2REQsMCvxcJw==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.4':
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
'@protobufjs/eventemitter@1.1.0':
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
'@protobufjs/fetch@1.1.0':
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/inquire@1.1.0':
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@ -4923,6 +5024,9 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@ -5497,12 +5601,12 @@ packages:
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
engines: {node: '>=12'}
posthog-js@1.310.1:
resolution: {integrity: sha512-UkR6zzlWNtqHDXHJl2Yk062DOmZyVKTPL5mX4j4V+u3RiYbMHJe47+PpMMUsvK1R2e1r/m9uSlHaJMJRzyUjGg==}
posthog-js@1.334.1:
resolution: {integrity: sha512-5cDzLICr2afnwX/cR9fwoLC0vN0Nb5gP5HiCigzHkgHdO+E3WsYefla3EFMQz7U4r01CBPZ+nZ9/srkzeACxtQ==}
posthog-node@5.18.0:
resolution: {integrity: sha512-SLBEs+sCThxzTGSSDEe97nZHuFFYh6DupObR1yQdvQND3CJh0ogZ0Sa1Vb+Tbrnf0cWbfBC9XNkm44yhaWf3aA==}
engines: {node: '>=20'}
posthog-node@5.24.1:
resolution: {integrity: sha512-1+wsosb5fjuor9zpp3h2uq0xKYY7rDz8gpw/10Scz8Ob/uVNrsHSwGy76D9rgt4cfyaEgpJwyYv+hPi2+YjWtw==}
engines: {node: ^20.20.0 || >=22.22.0}
preact@10.28.1:
resolution: {integrity: sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==}
@ -5626,6 +5730,10 @@ packages:
prosemirror-view@1.41.4:
resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==}
protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
@ -5637,6 +5745,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
query-selector-shadow-dom@1.0.1:
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -6472,8 +6583,8 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
web-vitals@5.1.0:
resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
@ -7611,8 +7722,82 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@opentelemetry/api-logs@0.208.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api@1.9.0': {}
'@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
protobufjs: 7.5.4
'@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/semantic-conventions@1.39.0': {}
'@orama/orama@3.1.18': {}
'@parcel/watcher-android-arm64@2.5.1':
@ -7675,17 +7860,19 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1
'@posthog/core@1.9.0':
'@posthog/core@1.13.0':
dependencies:
cross-spawn: 7.0.6
'@posthog/react@1.5.2(@types/react@19.2.7)(posthog-js@1.310.1)(react@19.2.3)':
'@posthog/react@1.7.0(@types/react@19.2.7)(posthog-js@1.334.1)(react@19.2.3)':
dependencies:
posthog-js: 1.310.1
posthog-js: 1.334.1
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.7
'@posthog/types@1.334.1': {}
'@prisma/client@4.8.1':
dependencies:
'@prisma/engines-version': 4.8.0-61.d6e67a83f971b175a593ccc12e15c4a757f93ffe
@ -7694,6 +7881,29 @@ snapshots:
'@prisma/engines-version@4.8.0-61.d6e67a83f971b175a593ccc12e15c4a757f93ffe':
optional: true
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.4': {}
'@protobufjs/eventemitter@1.1.0': {}
'@protobufjs/fetch@1.1.0':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/float@1.0.2': {}
'@protobufjs/inquire@1.1.0': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.0': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.0.0':
@ -11383,6 +11593,8 @@ snapshots:
lodash.merge@4.6.2: {}
long@5.3.2: {}
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@ -12272,17 +12484,25 @@ snapshots:
postgres@3.4.7: {}
posthog-js@1.310.1:
posthog-js@1.334.1:
dependencies:
'@posthog/core': 1.9.0
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@posthog/core': 1.13.0
'@posthog/types': 1.334.1
core-js: 3.47.0
dompurify: 3.3.1
fflate: 0.4.8
preact: 10.28.1
web-vitals: 4.2.4
query-selector-shadow-dom: 1.0.1
web-vitals: 5.1.0
posthog-node@5.18.0:
posthog-node@5.24.1:
dependencies:
'@posthog/core': 1.9.0
'@posthog/core': 1.13.0
preact@10.28.1: {}
@ -12433,6 +12653,21 @@ snapshots:
prosemirror-state: 1.4.4
prosemirror-transform: 1.10.5
protobufjs@7.5.4:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.4
'@protobufjs/eventemitter': 1.1.0
'@protobufjs/fetch': 1.1.0
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 20.19.27
long: 5.3.2
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
@ -12443,6 +12678,8 @@ snapshots:
punycode@2.3.1: {}
query-selector-shadow-dom@1.0.1: {}
queue-microtask@1.2.3: {}
rc@1.2.8:
@ -13524,7 +13761,7 @@ snapshots:
web-namespaces@2.0.1: {}
web-vitals@4.2.4: {}
web-vitals@5.1.0: {}
webidl-conversions@7.0.0: {}

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<defs>
<linearGradient id="obsidian-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7C3AED"/>
<stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
</defs>
<path d="M50 5 L90 35 L75 95 L25 95 L10 35 Z" fill="url(#obsidian-gradient)" stroke="#6D28D9" stroke-width="2"/>
<path d="M50 20 L70 38 L62 75 L38 75 L30 38 Z" fill="#A78BFA" opacity="0.4"/>
<path d="M50 5 L50 95" stroke="#8B5CF6" stroke-width="1" opacity="0.5"/>
<path d="M10 35 L90 35" stroke="#8B5CF6" stroke-width="1" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 657 B