diff --git a/pipes/smarttrend/.gitignore b/pipes/smarttrend/.gitignore new file mode 100644 index 0000000000..5ef6a52078 --- /dev/null +++ b/pipes/smarttrend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/pipes/smarttrend/LICENSE b/pipes/smarttrend/LICENSE new file mode 100644 index 0000000000..55f64a8867 --- /dev/null +++ b/pipes/smarttrend/LICENSE @@ -0,0 +1,7 @@ +Copyright © 2025 giraffekey + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/pipes/smarttrend/README.md b/pipes/smarttrend/README.md new file mode 100644 index 0000000000..d4bf2e0823 --- /dev/null +++ b/pipes/smarttrend/README.md @@ -0,0 +1,24 @@ +# **SmartTrend: Your Twitter Engagement Assistant** + +![SmartTrend Preview](https://raw.githubusercontent.com/mediar-ai/screenpipe/3163dbf999a50db79e168c965dc3cc5ace00c22d/pipes/smarttrend/preview.png) + +**SmartTrend** is an AI-powered Twitter assistant that helps you discover trending topics, generate meaningful replies, and boost your engagement effortlessly. Whether you want to grow your following, maintain active discussions, or simply stay relevant, SmartTrend makes it easy by analyzing your timeline, profile, and interactions to suggest optimized replies. + +--- + +## 🚀 **Key Features** +- **Intelligent Suggestions:** Analyzes your timeline to find tweets that align with your interests and suggests personalized replies. +- **Dynamic Frequency Control:** Adjusts how often it scans, analyzes, and generates suggestions based on your preferences. +- **Adaptive Writing Style:** Matches your tone, grammar, and formatting style, from casual to professional. +- **Engagement Filters:** Focuses on high-impact tweets with options to prioritize verified accounts, popular hashtags, or your followers. + +--- + +## 🧠 **How It Works** +1. **Timeline Analysis:** Uses advanced scraping to extract tweets without API limits. +2. **AI-Powered Insights:** Leverages OpenAI for reply suggestions based on your profile and interactions. +3. **Local Data Storage:** Keeps everything private by storing data locally. + +--- + +Engage smarter, not harder! diff --git a/pipes/smarttrend/bun.lockb b/pipes/smarttrend/bun.lockb new file mode 100755 index 0000000000..e2fd1ca8c9 Binary files /dev/null and b/pipes/smarttrend/bun.lockb differ diff --git a/pipes/smarttrend/components.json b/pipes/smarttrend/components.json new file mode 100644 index 0000000000..7b17557fb3 --- /dev/null +++ b/pipes/smarttrend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/pipes/smarttrend/eslint.config.mjs b/pipes/smarttrend/eslint.config.mjs new file mode 100644 index 0000000000..c85fb67c46 --- /dev/null +++ b/pipes/smarttrend/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/pipes/smarttrend/next-env.d.ts b/pipes/smarttrend/next-env.d.ts new file mode 100644 index 0000000000..1b3be0840f --- /dev/null +++ b/pipes/smarttrend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/pipes/smarttrend/next.config.ts b/pipes/smarttrend/next.config.ts new file mode 100644 index 0000000000..ac5ddcd49c --- /dev/null +++ b/pipes/smarttrend/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ["@screenpipe/js"], + webpack: (config, {}) => { + return config; + }, + devIndicators: { + buildActivity: false, + appIsrStatus: false, + }, +}; + +export default nextConfig; diff --git a/pipes/smarttrend/package.json b/pipes/smarttrend/package.json new file mode 100644 index 0000000000..68d5114bb9 --- /dev/null +++ b/pipes/smarttrend/package.json @@ -0,0 +1,83 @@ +{ + "name": "smarttrend-pipe", + "version": "0.1.3", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --turbopack", + "build": "next build --no-lint", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@ai-sdk/openai": "^1.2.4", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-tooltip": "^1.1.8", + "@screenpipe/browser": "^0.1.37", + "@screenpipe/js": "latest", + "@shadcn/ui": "^0.0.4", + "@tanstack/react-query": "^5.67.3", + "@types/js-levenshtein": "^1.1.3", + "@types/lodash": "^4.17.16", + "@types/react-syntax-highlighter": "^15.5.13", + "ai": "^4.1.60", + "class-variance-authority": "^0.7.1", + "classic-level": "^2.0.0", + "clsx": "^2.1.1", + "cmdk": "1.0.4", + "date-fns": "^4.1.0", + "dotenv": "^16.4.7", + "framer-motion": "^11.18.2", + "install": "^0.13.0", + "js-levenshtein": "^1.1.6", + "localforage": "^1.10.0", + "lodash": "^4.17.21", + "lucide-react": "^0.468.0", + "magic-ui": "^0.1.0", + "next": "15.1.0", + "node-cron": "^3.0.3", + "npm": "^10.9.2", + "ollama": "^0.5.14", + "ollama-ai-provider": "^1.2.0", + "open": "^10.1.0", + "openai": "^4.87.3", + "puppeteer-core": "^24.4.0", + "react": "^19.0.0", + "react-day-picker": "8.10.1", + "react-dom": "^19.0.0", + "react-markdown": "^9.1.0", + "react-syntax-highlighter": "^15.6.1", + "react-textarea-autosize": "^8.5.7", + "react-twitter-embed": "^4.0.4", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "sonner": "^2.0.1", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.0", + "@types/node": "^22.13.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "autoprefixer": "^10.4.21", + "eslint": "^9.22.0", + "eslint-config-next": "15.1.0", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.2", + "bun-types": "latest" + } +} diff --git a/pipes/smarttrend/pipe.json b/pipes/smarttrend/pipe.json new file mode 100644 index 0000000000..f6218dda10 --- /dev/null +++ b/pipes/smarttrend/pipe.json @@ -0,0 +1,3 @@ +{ + "crons": [] +} diff --git a/pipes/smarttrend/postcss.config.mjs b/pipes/smarttrend/postcss.config.mjs new file mode 100644 index 0000000000..1a69fd2a45 --- /dev/null +++ b/pipes/smarttrend/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/pipes/smarttrend/public/file.svg b/pipes/smarttrend/public/file.svg new file mode 100644 index 0000000000..004145cddf --- /dev/null +++ b/pipes/smarttrend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pipes/smarttrend/public/globe.svg b/pipes/smarttrend/public/globe.svg new file mode 100644 index 0000000000..567f17b0d7 --- /dev/null +++ b/pipes/smarttrend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pipes/smarttrend/public/next.svg b/pipes/smarttrend/public/next.svg new file mode 100644 index 0000000000..5174b28c56 --- /dev/null +++ b/pipes/smarttrend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pipes/smarttrend/public/vercel.svg b/pipes/smarttrend/public/vercel.svg new file mode 100644 index 0000000000..7705396033 --- /dev/null +++ b/pipes/smarttrend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pipes/smarttrend/public/window.svg b/pipes/smarttrend/public/window.svg new file mode 100644 index 0000000000..b2b2a44f6e --- /dev/null +++ b/pipes/smarttrend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pipes/smarttrend/src/app/api/errors/route.ts b/pipes/smarttrend/src/app/api/errors/route.ts new file mode 100644 index 0000000000..7d2ff2c131 --- /dev/null +++ b/pipes/smarttrend/src/app/api/errors/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server"; +import { eventEmitter } from "@/lib/events"; + +export interface Error { + title: string; + description: string; +} + +export async function GET(req: NextRequest) { + const stream = new ReadableStream({ + start(controller) { + const sendData = (data: Error) => { + controller.enqueue(`data: ${JSON.stringify(data)}\n\n`); + }; + + eventEmitter.on("catchError", sendData); + + const keepAliveInterval = setInterval(() => { + controller.enqueue(new TextEncoder().encode(": keep-alive\n\n")); + }, 30_000); + + req.signal.addEventListener("abort", () => { + console.log("/api/errors: Connection Closed"); + eventEmitter.off("catchError", sendData); + clearInterval(keepAliveInterval); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/pipes/smarttrend/src/app/api/progress/route.ts b/pipes/smarttrend/src/app/api/progress/route.ts new file mode 100644 index 0000000000..cf28c22d84 --- /dev/null +++ b/pipes/smarttrend/src/app/api/progress/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server"; +import { eventEmitter } from "@/lib/events"; + +export interface ProgressUpdate { + process: number; + value: number; +} + +export async function GET(req: NextRequest) { + const stream = new ReadableStream({ + start(controller) { + const sendData = (data: ProgressUpdate) => { + controller.enqueue(`data: ${JSON.stringify(data)}\n\n`); + }; + + eventEmitter.on("updateProgress", sendData); + + const keepAliveInterval = setInterval(() => { + controller.enqueue(new TextEncoder().encode(": keep-alive\n\n")); + }, 30_000); + + req.signal.addEventListener("abort", () => { + console.log("/api/progress: Connection Closed"); + eventEmitter.off("updateProgress", sendData); + clearInterval(keepAliveInterval); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/pipes/smarttrend/src/app/api/settings/route.ts b/pipes/smarttrend/src/app/api/settings/route.ts new file mode 100644 index 0000000000..3a6cc2fa5a --- /dev/null +++ b/pipes/smarttrend/src/app/api/settings/route.ts @@ -0,0 +1,109 @@ +import { pipe } from "@screenpipe/js"; +import { NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const settingsManager = pipe.settings; + if (!settingsManager) { + throw new Error("settingsManager not found"); + } + + // Load persisted settings if they exist + const screenpipeDir = process.env.SCREENPIPE_DIR || process.cwd(); + const settingsPath = path.join( + screenpipeDir, + "pipes", + "obsidian", + "pipe.json", + ); + + try { + const settingsContent = await fs.readFile(settingsPath, "utf8"); + const persistedSettings = JSON.parse(settingsContent); + + // Merge with current settings + const rawSettings = await settingsManager.getAll(); + return NextResponse.json({ + ...rawSettings, + customSettings: { + ...rawSettings.customSettings, + ["obsidian"]: { + ...(rawSettings.customSettings?.["obsidian"] || {}), + ...persistedSettings, + }, + }, + }); + } catch (err) { + // If no persisted settings, return normal settings + const rawSettings = await settingsManager.getAll(); + return NextResponse.json(rawSettings); + } + } catch (error) { + console.error("failed to get settings:", error); + return NextResponse.json( + { error: "failed to get settings" }, + { status: 500 }, + ); + } +} + +export async function PUT(request: Request) { + try { + const settingsManager = pipe.settings; + if (!settingsManager) { + throw new Error("settingsManager not found"); + } + + const body = await request.json(); + const { key, value, isPartialUpdate, reset, namespace } = body; + + if (reset) { + if (namespace) { + if (key) { + await settingsManager.setCustomSetting(namespace, key, undefined); + } else { + await settingsManager.updateNamespaceSettings(namespace, {}); + } + } else { + if (key) { + await settingsManager.resetKey(key); + } else { + await settingsManager.reset(); + } + } + return NextResponse.json({ success: true }); + } + + if (namespace) { + if (isPartialUpdate) { + const currentSettings = + (await settingsManager.getNamespaceSettings(namespace)) || {}; + await settingsManager.updateNamespaceSettings(namespace, { + ...currentSettings, + ...value, + }); + } else { + await settingsManager.setCustomSetting(namespace, key, value); + } + } else if (isPartialUpdate) { + const serializedSettings = JSON.parse(JSON.stringify(value)); + await settingsManager.update(serializedSettings); + } else { + const serializedValue = JSON.parse(JSON.stringify(value)); + await settingsManager.set(key, serializedValue); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("failed to update settings:", error); + return NextResponse.json( + { error: "failed to update settings" }, + { status: 500 }, + ); + } +} diff --git a/pipes/smarttrend/src/app/api/suggestions/route.ts b/pipes/smarttrend/src/app/api/suggestions/route.ts new file mode 100644 index 0000000000..f1e28854da --- /dev/null +++ b/pipes/smarttrend/src/app/api/suggestions/route.ts @@ -0,0 +1,34 @@ +import { NextRequest } from "next/server"; +import { eventEmitter } from "@/lib/events"; +import type { Suggestion } from "@/lib/actions/run-bot"; + +export async function GET(req: NextRequest) { + const stream = new ReadableStream({ + start(controller) { + const sendData = (data: Suggestion) => { + controller.enqueue(`data: ${JSON.stringify(data)}\n\n`); + }; + + eventEmitter.on("addSuggestion", sendData); + + const keepAliveInterval = setInterval(() => { + controller.enqueue(new TextEncoder().encode(": keep-alive\n\n")); + }, 30_000); + + req.signal.addEventListener("abort", () => { + console.log("/api/suggestions: Connection Closed"); + eventEmitter.off("addSuggestion", sendData); + clearInterval(keepAliveInterval); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/pipes/smarttrend/src/app/favicon.ico b/pipes/smarttrend/src/app/favicon.ico new file mode 100644 index 0000000000..5be4ba27ad Binary files /dev/null and b/pipes/smarttrend/src/app/favicon.ico differ diff --git a/pipes/smarttrend/src/app/globals.css b/pipes/smarttrend/src/app/globals.css new file mode 100644 index 0000000000..825abc6051 --- /dev/null +++ b/pipes/smarttrend/src/app/globals.css @@ -0,0 +1,78 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; + min-width: 300px; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground !pointer-events-auto; + } +} + +.twitter-tweet { + margin-top: 0 !important; + margin-bottom: 0 !important; +} diff --git a/pipes/smarttrend/src/app/layout.tsx b/pipes/smarttrend/src/app/layout.tsx new file mode 100644 index 0000000000..1828357a6b --- /dev/null +++ b/pipes/smarttrend/src/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Toaster } from "@/components/ui/toaster"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "SmartTrend • Screenpipe", + description: "Optimize your tweet engagement with AI", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/pipes/smarttrend/src/app/page.tsx b/pipes/smarttrend/src/app/page.tsx new file mode 100644 index 0000000000..c9eac4793e --- /dev/null +++ b/pipes/smarttrend/src/app/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import { AIPresetsDialog } from "@/components/ai-presets-dialog"; +import { AIPresetsSelector } from "@/components/ai-presets-selector"; +import { ConnectionPanel } from "@/components/connection-panel"; +import { ControlPanel } from "@/components/control-panel"; +import { Status } from "@/components/status"; +import { FrequencySlider } from "@/components/frequency-slider"; +import { PromptInput } from "@/components/prompt-input"; +import { SuggestionList } from "@/components/suggestion-list"; +import { useToast } from "@/hooks/use-toast"; +import * as store from "@/lib/store"; +import type { Error } from "@/app/api/errors/route"; +import type { CookieParam } from "puppeteer-core"; + +export default function Page() { + const [cookies, setCookies] = useState([]); + const [isRunning, setIsRunning] = useState(false); + const [frequency, setFrequency] = useState(5); + const [prompt, setPrompt] = useState(""); + const { toast } = useToast(); + + const isConnected = useMemo(() => cookies.length > 0, [cookies]); + + useEffect(() => { + store.getCookies().then(setCookies); + store.getPrompt().then(setPrompt); + + const eventSource = new EventSource("/api/errors"); + + eventSource.onmessage = (event) => { + try { + const e: Error = JSON.parse(event.data); + toast({ + title: e.title, + description: e.description, + variant: "destructive", + }); + } catch (e) { + console.error("Failed to capture errors:", e); + } + }; + + eventSource.onerror = (e) => { + console.error("Failed to capture errors:", e); + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, []); + + return ( +
+
+

SmartTrend

+
+
+
+ + + {isRunning && } + {!isRunning && ( + + )} + {!isRunning && } +
+ + +
+
+ +
+
+ ); +} diff --git a/pipes/smarttrend/src/components/ai-presets-dialog.tsx b/pipes/smarttrend/src/components/ai-presets-dialog.tsx new file mode 100644 index 0000000000..60d3adfb0f --- /dev/null +++ b/pipes/smarttrend/src/components/ai-presets-dialog.tsx @@ -0,0 +1,1302 @@ +import { usePipeSettings } from "@/lib/hooks/use-pipe-settings"; +import { useSettings, type PipeSettings } from "@/lib/hooks/use-settings"; +import { useMemo, useState, useEffect } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; +import { Button } from "./ui/button"; +import { + Check, + Plus, + Copy, + Edit2, + Star, + Trash2, + Settings, + Terminal, + Loader2, + HelpCircle, + Eye, + EyeOff, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { Textarea } from "./ui/textarea"; +import { Slider } from "./ui/slider"; + +export const Icons = { + openai: (props: any) => ( + + + + ), + settings: Settings, + terminal: Terminal, + spinner: Loader2, +}; + +interface BaseAIPreset { + id: string; + maxContextChars: number; + url: string; + model: string; + defaultPreset: boolean; + prompt: string; +} + +type AIPreset = BaseAIPreset & + ( + | { + provider: "openai"; + apiKey: string; + } + | { + provider: "native-ollama"; + } + | { + provider: "screenpipe-cloud"; + } + | { + provider: "custom"; + apiKey?: string; + } + ); + +interface BaseRecommendedPreset { + id: string; + maxContextChars: number; + model: string; + prompt: string; +} + +type RecommendedPreset = BaseRecommendedPreset & + ( + | { + provider: "openai"; + } + | { + provider: "native-ollama"; + } + | { + provider: "screenpipe-cloud"; + } + ); + +interface AIProviderConfigProps { + onSubmit: (data: AIProviderData) => void; + defaultPreset?: { + provider: "openai" | "native-ollama" | "custom" | "screenpipe-cloud"; + apiKey?: string; + baseUrl?: string; + modelName?: string; + maxContextChars?: number; + prompt?: string; + id?: string; + }; +} + +interface AIProviderData { + provider: "openai" | "native-ollama" | "custom" | "screenpipe-cloud"; + apiKey?: string; + baseUrl?: string; + modelName?: string; + maxContextChars?: number; + prompt?: string; + id?: string; +} + +interface OpenAIModel { + id: string; + created?: number; + owned_by?: string; +} + +export const DEFAULT_PROMPT = `Rules: +- You can analyze/view/show/access videos to the user by putting .mp4 files in a code block (we'll render it) like this: \`/users/video.mp4\`, use the exact, absolute, file path from file_path property +- Do not try to embed video in links (e.g. [](.mp4) or https://.mp4) instead put the file_path in a code block using backticks +- Do not put video in multiline code block it will not render the video (e.g. \`\`\`bash\n.mp4\`\`\` IS WRONG) instead using inline code block with single backtick +- Always answer my question/intent, do not make up things +`; + +export function AIProviderConfig({ + onSubmit, + defaultPreset, +}: AIProviderConfigProps) { + const [selectedProvider, setSelectedProvider] = useState< + AIProviderData["provider"] + >(defaultPreset?.provider || "openai"); + const { settings } = useSettings(); + const [isLoading, setIsLoading] = useState(false); + const [openaiModels, setOpenAIModels] = useState([]); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [idError, setIdError] = useState(null); + const [showApiKey, setShowApiKey] = useState(false); + const [formData, setFormData] = useState({ + provider: defaultPreset?.provider || "openai", + apiKey: defaultPreset?.apiKey || "", + baseUrl: defaultPreset?.baseUrl || "", + modelName: defaultPreset?.modelName || "", + maxContextChars: defaultPreset?.maxContextChars || 512000, + prompt: defaultPreset?.prompt || DEFAULT_PROMPT, + id: defaultPreset?.id || "", + }); + + const validateId = (id: string | undefined): boolean => { + if (!id?.trim()) { + setIdError("name is required"); + return false; + } + + // Check if ID ends with 'copy' (case insensitive) + if (id.trim().toLowerCase().endsWith("copy")) { + setIdError("name cannot end with 'copy'"); + return false; + } + + // Check for duplicate IDs, excluding the current preset being edited + const isDuplicate = settings?.aiPresets?.some( + (preset) => + preset.id.toLowerCase() === id.toLowerCase() && + preset.id !== defaultPreset?.id, + ); + + if (isDuplicate) { + setIdError("name already exists"); + return false; + } + + setIdError(null); + return true; + }; + + const handleIdChange = (value: string) => { + setFormData((prev) => ({ ...prev, id: value })); + validateId(value); + }; + + const fetchOpenAIModels = async (baseUrl: string, apiKey: string) => { + setIsLoadingModels(true); + try { + const response = await fetch(`${baseUrl}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("failed to fetch models"); + } + + const data = await response.json(); + setOpenAIModels(data.data || []); + } catch (error) { + console.error("error fetching models:", error); + setOpenAIModels([]); + } finally { + setIsLoadingModels(false); + } + }; + + const fetchOllamaModels = async (baseUrl: string) => { + setIsLoadingModels(true); + try { + const response = await fetch(`${baseUrl}/models`); + + if (!response.ok) { + throw new Error("failed to fetch ollama models"); + } + + const data = (await response.json()) as { + data: OpenAIModel[]; + }; + setOpenAIModels(data.data || []); + } catch (error) { + console.error("error fetching ollama models:", error); + setOpenAIModels([]); + } finally { + setIsLoadingModels(false); + } + }; + + useEffect(() => { + setOpenAIModels([]); + if (selectedProvider === "openai" && formData.apiKey) { + setOpenAIModels([ + { id: "gpt-4" }, + { + id: "gpt-3.5-turbo", + }, + ]); + } else if (selectedProvider === "native-ollama") { + const baseUrl = "http://localhost:11434/v1"; + fetchOllamaModels(baseUrl); + } else if (selectedProvider === "screenpipe-cloud") { + fetchOpenAIModels( + "https://ai-proxy.i-f9f.workers.dev/v1", + settings?.user?.token ?? "", + ); + } else if ( + selectedProvider === "custom" && + formData.baseUrl && + formData.apiKey + ) { + fetchOpenAIModels(formData.baseUrl, formData.apiKey); + } + }, [selectedProvider, formData.apiKey, formData.baseUrl]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateId(formData.id)) { + return; + } + + setIsLoading(true); + try { + onSubmit({ + ...formData, + id: formData.id?.trim() || "", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

+ {defaultPreset?.id ? "edit ai provider" : "ai provider"} +

+

+ {defaultPreset?.id + ? "modify your ai provider settings" + : "configure your ai provider settings"} +

+
+ +
+
+ + handleIdChange(e.target.value)} + className={cn( + "font-mono", + idError && "border-destructive focus-visible:ring-destructive", + )} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + disabled={ + Boolean(defaultPreset?.id) && + !defaultPreset?.id?.endsWith("-copy") + } + /> +
+ +
+ + + + + + + +
+ + {selectedProvider === "openai" && ( +
+
+ +
+ + setFormData({ ...formData, apiKey: e.target.value }) + } + className="pr-10" + /> + +
+
+
+ + +
+
+ )} + + {selectedProvider === "native-ollama" && ( +
+
+ + + setFormData({ ...formData, baseUrl: e.target.value }) + } + /> +
+
+ + +
+
+ )} + + {selectedProvider === "screenpipe-cloud" && ( +
+
+ + +
+
+ )} + + {selectedProvider === "custom" && ( +
+
+ + + setFormData({ ...formData, baseUrl: e.target.value }) + } + /> +
+
+ +
+ + setFormData({ ...formData, apiKey: e.target.value }) + } + className="pr-10" + /> + +
+
+
+ + +
+
+ )} + +
+
+ +
+ + setFormData({ ...formData, maxContextChars: value }) + } + className="flex-grow" + /> + + {((formData.maxContextChars || 512000) / 1000).toFixed(0)}k + +
+
+ +
+ +