diff --git a/package.json b/package.json index dbadf0bd..8300938b 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "tailwind-merge": "^3.2.0", "tailwind-scrollbar": "4.0.2", "tailwindcss-animate": "^1.0.7", + "telegraf": "^4.16.3", "twitter-api-v2": "^1.22.0", "viem": "~2.28.1", "wagmi": "^2.15.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6811e45..9decd4d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.1.5) + telegraf: + specifier: ^4.16.3 + version: 4.16.3 twitter-api-v2: specifier: ^1.22.0 version: 1.22.0 @@ -2880,6 +2883,9 @@ packages: '@tanstack/virtual-core@3.13.6': resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} + '@telegraf/types@7.1.0': + resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==} + '@trivago/prettier-plugin-sort-imports@5.2.2': resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==} engines: {node: '>18.12'} @@ -3583,6 +3589,15 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + + buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + + buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5476,6 +5491,10 @@ packages: motion-utils@12.8.3: resolution: {integrity: sha512-GYVauZEbca8/zOhEiYOY9/uJeedYQld6co/GJFKOy//0c/4lDqk0zB549sBYqqV2iMuX+uHrY1E5zd8A2L+1Lw==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -5677,6 +5696,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-timeout@4.1.0: + resolution: {integrity: sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -6197,6 +6220,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-compare@1.1.4: + resolution: {integrity: sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==} + safe-regex-test@1.1.0: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} @@ -6205,6 +6231,10 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + sandwich-stream@2.0.2: + resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==} + engines: {node: '>= 0.10'} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -6547,6 +6577,11 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + telegraf@4.16.3: + resolution: {integrity: sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==} + engines: {node: ^12.20.0 || >=14.13.1} + hasBin: true + terser@5.39.0: resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==} engines: {node: '>=10'} @@ -10560,6 +10595,8 @@ snapshots: '@tanstack/virtual-core@3.13.6': {} + '@telegraf/types@7.1.0': {} + '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.5.3)': dependencies: '@babel/generator': 7.26.10 @@ -11860,6 +11897,15 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-alloc-unsafe@1.1.0: {} + + buffer-alloc@1.2.0: + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + + buffer-fill@1.0.0: {} + buffer-from@1.1.2: {} buffer@6.0.3: @@ -14278,6 +14324,8 @@ snapshots: motion-utils@12.8.3: {} + mri@1.2.0: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -14498,6 +14546,8 @@ snapshots: dependencies: p-limit: 3.1.0 + p-timeout@4.1.0: {} + p-try@2.2.0: {} parent-module@1.0.1: @@ -15127,6 +15177,10 @@ snapshots: safe-buffer@5.2.1: {} + safe-compare@1.1.4: + dependencies: + buffer-alloc: 1.2.0 + safe-regex-test@1.1.0: dependencies: call-bound: 1.0.3 @@ -15135,6 +15189,8 @@ snapshots: safe-stable-stringify@2.5.0: {} + sandwich-stream@2.0.2: {} + scheduler@0.25.0: {} scheduler@0.26.0: {} @@ -15527,6 +15583,20 @@ snapshots: tapable@2.2.1: {} + telegraf@4.16.3: + dependencies: + '@telegraf/types': 7.1.0 + abort-controller: 3.0.0 + debug: 4.4.0(supports-color@5.5.0) + mri: 1.2.0 + node-fetch: 2.7.0 + p-timeout: 4.1.0 + safe-compare: 1.1.4 + sandwich-stream: 2.0.2 + transitivePeerDependencies: + - encoding + - supports-color + terser@5.39.0: dependencies: '@jridgewell/source-map': 0.3.6 diff --git a/scripts/send-newsletter.ts b/scripts/send-newsletter.ts new file mode 100644 index 00000000..2db4baa0 --- /dev/null +++ b/scripts/send-newsletter.ts @@ -0,0 +1,43 @@ +/** + * Send Newsletter Script + * + * This script triggers the newsletter API to send newsletters to all subscribers. + * + * Usage: + * pnpm ts-node scripts/send-newsletter.ts + */ + +async function sendNewsletter() { + try { + const apiUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001"; + const response = await fetch(`${apiUrl}/api/newsletter`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + const result = await response.json(); + console.log("Newsletter distribution results:", result); + + if (result.success) { + console.log( + `Successfully sent to ${result.results.successful} out of ${result.results.total} subscribers` + ); + + if (result.results.failed > 0) { + console.warn(`Failed to send to ${result.results.failed} subscribers`); + if (result.results.errors) { + console.warn("Errors:", result.results.errors); + } + } + } else { + console.error("Failed to send newsletters:", result.error); + } + } catch (error) { + console.error("Error triggering newsletter API:", error); + } +} + +// Run the function +sendNewsletter(); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index a16f6ba6..949f4996 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,39 +1,35 @@ import { NextResponse } from "next/server"; -//import { anthropic } from "@ai-sdk/anthropic"; -//import { deepseek } from "@ai-sdk/deepseek"; -import { google } from "@ai-sdk/google"; import { xai } from "@ai-sdk/xai"; -//import { openai } from "@ai-sdk/openai"; -import { EVM, createConfig } from "@lifi/sdk"; -import { SupabaseClient, createClient } from "@supabase/supabase-js"; +import { createClient } from "@supabase/supabase-js"; import { CoreMessage, experimental_createMCPClient as createMCPClient, - generateText, streamText, tool, } from "ai"; -import { createWalletClient, http } from "viem"; -import type { Chain as vChain } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import { base, mode } from "viem/chains"; import { z } from "zod"; +import { initializeBlockchainConfig } from "@/lib/blockchainConfig"; import { CHAINS } from "@/lib/chains"; +import { saveChatToDatabase } from "@/lib/chatStorageManager"; import { filterAndSimplifyHistoryForLLM } from "@/lib/messageUtils"; import { getMorpheusSearchRawStream } from "@/lib/morpheusSearch"; +import { formatResponseToObject } from "@/lib/responseFormatter"; import { incrementMessageUsage } from "@/lib/userManager"; import { systemPrompt } from "./systemPrompt"; -import { UIMessage } from "./tools/types"; -const supabaseWrite: SupabaseClient = createClient( +// Initialize blockchain configuration +initializeBlockchainConfig(); + +const supabaseWrite = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_KEY! ); type MCPClient = Awaited>; + // Add an explicit save endpoint for chats that can be called directly export async function PUT(req: Request) { const requestId = `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -48,6 +44,8 @@ export async function PUT(req: Request) { const id = body.id; const walletAddress = body.wallet_address || body.address; const messages = body.messages; + const isFavorite = + body.is_favorite !== undefined ? body.is_favorite : false; console.log( `[${requestId}] Save request for chat id: ${id}, wallet: ${walletAddress}, msg count: ${messages?.length}` @@ -76,82 +74,31 @@ export async function PUT(req: Request) { console.warn(`[${requestId}] Empty or invalid messages array`); } - // Generate a title - let title = "New Conversation"; - try { - title = await generateConversationTitle(messages || []); - } catch (titleError) { - console.log(`[${requestId}] Title generation error:`, titleError); - // Fall back to first message or default - title = - messages && messages.length > 0 - ? messages[0]?.content?.slice(0, 80) || "New Conversation" - : "New Conversation"; - } - - // Extract message content for user and assistant - const userMessage = messages?.find((m: UIMessage) => m.role === "user"); - const assistantMessage = messages - ?.filter((m: UIMessage) => m.role === "assistant") - .pop(); - - const saveData = { + const saveResult = await saveChatToDatabase( + supabaseWrite, id, - wallet_address: walletAddress, - label: title, - prompt: userMessage?.content || "", - response: - typeof assistantMessage?.content === "string" - ? assistantMessage.content - : "Processing...", - messages: messages || [], // Save the full original messages - is_favorite: body.is_favorite !== undefined ? body.is_favorite : false, - }; - - console.log(`[${requestId}] Save data prepared:`, { - id: saveData.id, - wallet_address: saveData.wallet_address, - title_length: saveData.label.length, - message_count: saveData.messages.length, - is_favorite: saveData.is_favorite, - }); - - try { - console.log(`[${requestId}] Executing upsert to saved_chats table...`); - const { error } = await supabaseWrite - .from("saved_chats") - .upsert([saveData], { - onConflict: "id", - }); - - if (error) { - console.error(`[${requestId}] Save error:`, error); - return NextResponse.json( - { - error: `Failed to save chat: ${error.message}`, - details: error, - requestId, - }, - { status: 500 } - ); - } + walletAddress, + messages, + isFavorite, + requestId + ); - console.log(`[${requestId}] Chat saved successfully`); - return NextResponse.json({ - success: true, - message: "Chat saved successfully", - requestId, - }); - } catch (error) { - console.error(`[${requestId}] Database error:`, error); + if (!saveResult.success) { return NextResponse.json( { - error: `Database error: ${error instanceof Error ? error.message : String(error)}`, + error: `Failed to save chat: ${saveResult.error?.message}`, + details: saveResult.error, requestId, }, { status: 500 } ); } + + return NextResponse.json({ + success: true, + message: "Chat saved successfully", + requestId, + }); } catch (error) { console.error(`[${requestId}] Unhandled exception in PUT:`, error); return NextResponse.json( @@ -167,79 +114,6 @@ export async function PUT(req: Request) { export const maxDuration = 120; -const account = privateKeyToAccount( - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" -); // dummy key -const chains = [base, mode]; // Add other chains if needed - -const client = createWalletClient({ - account, - chain: base, - transport: http(), -}); - -createConfig({ - integrator: "ionic", - providers: [ - EVM({ - getWalletClient: async () => client, - switchChain: async (chainId: number) => - // Switch chain by creating a new wallet client - createWalletClient({ - account, - chain: chains.find(chain => chain.id == chainId) as vChain, - transport: http(), - }), - }), - ], -}); - -const titleCache = new Map(); - -// Title Generation based on the First message -async function generateConversationTitle( - messages: UIMessage[] -): Promise { - try { - const firstUserMessage = messages.find( - (m: UIMessage) => m.role === "user" && m.content?.trim().length > 0 - ); - if (!firstUserMessage) return "New Conversation"; // Handle case with no user message - - const cacheKey = `${firstUserMessage.content.slice(0, 100)}|${firstUserMessage.id}`; - if (titleCache.has(cacheKey)) { - return titleCache.get(cacheKey)!; - } - const { text: title } = await generateText({ - model: google("gemini-2.0-flash-001"), - system: `Generate a concise 4-8 word title for this user request. Focus on the main action, asset, or topic. Examples: "Check ETH Balance", "Swap USDC to WETH", "Bitcoin Price Analysis", "Ionic Lend Position". Respond ONLY with the title text, no quotes or punctuation.`, - messages: [ - { - role: "user", - content: `Request: ${firstUserMessage.content.slice(0, 300)}`, - }, - ], - maxTokens: 20, - }); - - const cleanTitle = title.trim().replace(/["'.]/g, "").slice(0, 80); - - const finalTitle = cleanTitle || firstUserMessage.content.slice(0, 80); - - // Cache and return - titleCache.set(cacheKey, finalTitle); - return finalTitle; - } catch (error) { - console.error("Title generation failed:", error); - // Fallback logic - const firstMessageContent = messages[0]?.content; - return typeof firstMessageContent === "string" - ? firstMessageContent.slice(0, 80).trim() + - (firstMessageContent.length > 80 ? "..." : "") - : "New Conversation"; - } -} - export async function POST(req: Request) { const requestId = `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; let id: string | undefined; @@ -355,39 +229,17 @@ export async function POST(req: Request) { ); } - const saveChatInitial = async () => { - try { - const title = await generateConversationTitle(originalMessages || []); - const userMessage = originalMessages?.find( - (m: UIMessage) => m.role === "user" - ); - const saveData = { - id, - wallet_address: address, - label: title, - prompt: userMessage?.content || "", - response: "Processing the request...", - messages: originalMessages || [], - is_favorite: false, - }; - console.log(`[${requestId}] Performing initial save/upsert...`); - const { error } = await supabaseWrite - .from("saved_chats") - .upsert([saveData], { - onConflict: "id", - }); - if (error) { - console.error(`[${requestId}] Error during initial save:`, error); - } else { - console.log(`[${requestId}] Initial save successful.`); - } - } catch (e) { - console.error(`[${requestId}] Exception during initial save:`, e); - } - }; - await saveChatInitial(); + // Initial save of the chat + await saveChatToDatabase( + supabaseWrite, + id, + address, + originalMessages, + false, + requestId + ); - // Add filter new clietnt side tools here + // Add filter new client side tools here console.log( `[${requestId}] Sentinel: Original messages before potentially simplifying:`, JSON.stringify(originalMessages, null, 2) @@ -473,13 +325,7 @@ export async function POST(req: Request) { } const streamConfig = { - //model: deepseek("deepseek-chat"), - //model: anthropic("claude-3-5-sonnet-latest"), model: xai("grok-3-fast"), // Using flash version - //model: google('gemini-1.5-pro-latest'), - //model: anthropic("claude-3-haiku-20240307"), - //model: google("gemini-1.5-flash-latest"), - //model: openai.chat("gpt-4o"), messages: messagesToSendToModel, tools: { ...matrixMcpTools, // Include MCP tools safely @@ -571,6 +417,123 @@ export async function POST(req: Request) { .describe("The source chain being bridged from"), }), }), + link_telegram: tool({ + description: + "Link a Telegram account to the user's address. This tool is used to register for subscriptions for newsletters and other updates. Print the nonce to the user and tell them to send it to the Telegram bot.", + parameters: z.object({}), + execute: async ({}) => { + try { + // First, check if this address already has a linked Telegram account + const { data: existingLink, error: fetchError } = + await supabaseWrite + .from("user_telegram_subscriptions") + .select("telegram_id, linked") + .eq("user_address", address) + .single(); + + if (fetchError && fetchError.code !== "PGRST116") { + // PGRST116 is "no rows returned" + console.error( + `[${requestId}] Error checking existing telegram link:`, + fetchError + ); + return { + success: false, + error: "Failed to check existing Telegram link status.", + details: String(fetchError), + }; + } + + // If already linked and verified, return that information + if (existingLink?.linked && existingLink?.telegram_id) { + return { + success: true, + alreadyLinked: true, + message: + "Your account is already linked to a Telegram account.", + telegramId: existingLink.telegram_id, + }; + } + + // Generate a random nonce + const nonce = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + // Set expiry to 2 hours from now + const expiryDate = new Date(); + expiryDate.setHours(expiryDate.getHours() + 2); + + // First try to update if the record exists + const { data: updateData, error: updateError } = + await supabaseWrite + .from("user_telegram_subscriptions") + .update({ + nonce: nonce, + nonce_expiry: expiryDate.toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("user_address", address) + .select(); + + // If no records were updated (doesn't exist yet), then insert + if (!updateData || updateData.length === 0) { + const { error: insertError } = await supabaseWrite + .from("user_telegram_subscriptions") + .insert({ + user_address: address, + nonce: nonce, + nonce_expiry: expiryDate.toISOString(), + updated_at: new Date().toISOString(), + }); + + if (insertError) { + console.error( + `[${requestId}] Error inserting telegram subscription nonce:`, + insertError + ); + return { + success: false, + error: + "Failed to generate telegram linking token. Please try again.", + details: String(insertError), + }; + } + } else if (updateError) { + console.error( + `[${requestId}] Error updating telegram subscription nonce:`, + updateError + ); + return { + success: false, + error: + "Failed to update telegram linking token. Please try again.", + details: String(updateError), + }; + } + + return { + success: true, + nonce: nonce, + expiresAt: expiryDate.toISOString(), + message: + "Use this token in the Telegram bot to complete linking your account.", + instructions: `1. Open the Matrix AI Telegram bot\n2. Send the command /link ${nonce}`, + }; + } catch (error) { + console.error( + `[${requestId}] Exception in link_telegram:`, + error + ); + return { + success: false, + error: + "An unexpected error occurred while generating the linking token.", + details: error instanceof Error ? error.message : String(error), + }; + } + }, + }), }, async onFinish(finish: { response: { messages: any[] }; @@ -598,53 +561,21 @@ export async function POST(req: Request) { ); return; } - let title; - try { - title = await generateConversationTitle(originalMessages || []); - } catch (e) { - console.error( - `[${requestId}] Title generation error in onFinish:`, - e - ); - title = - originalMessages?.[0]?.content?.slice(0, 80) || - "New Conversation"; - } + const finalAssistantMessage = formatResponseToObject(response); const finalMessagesToSave = [ ...(originalMessages || []), finalAssistantMessage, ]; - const finalSaveData = { + await saveChatToDatabase( + supabaseWrite, id, - wallet_address: address, - label: title, - prompt: - originalMessages?.find((m: UIMessage) => m.role === "user") - ?.content || "", - response: finalAssistantMessage.content || "", - messages: finalMessagesToSave, - is_favorite: false, - }; - - console.log( - `[${requestId}] Preparing final save data. Message count: ${finalMessagesToSave.length}` + address, + finalMessagesToSave, + false, + requestId ); - const { error: finalSaveError } = await supabaseWrite - .from("saved_chats") - .upsert([finalSaveData], { - onConflict: "id", - }); - - if (finalSaveError) { - console.error( - `[${requestId}] Error during final save in onFinish:`, - finalSaveError - ); - } else { - console.log(`[${requestId}] Final save successful in onFinish.`); - } } catch (error) { console.error( `[${requestId}] Error processing in onFinish function:`, @@ -693,67 +624,3 @@ export async function POST(req: Request) { ); } } -function formatResponseToObject(response: { messages: any[] }): UIMessage { - const assistantMsg = response.messages[response.messages.length - 1]; - if (!assistantMsg || assistantMsg.role !== "assistant") { - console.warn( - "formatResponseToObject: Could not find valid assistant message in response. Returning placeholder." - ); - const errorText = "Error: Could not format response."; - return { - id: `msg-error-${Date.now()}`, - role: "assistant", - content: errorText, - parts: [{ type: "text", text: errorText }], - }; - } - - let textContent = ""; - const parts: any[] = []; - const toolInvocations: any[] = []; - - if (Array.isArray(assistantMsg.content)) { - assistantMsg.content.forEach((item: any) => { - if (item.type === "text") { - textContent += item.text; - parts.push({ type: "text", text: item.text }); - } else if (item.type === "tool-call") { - } else if (item.type === "tool-result") { - parts.push({ - type: "tool-invocation", - toolInvocation: { - state: "result", - toolCallId: item.toolCallId, - toolName: item.toolName, - args: item.args || {}, - result: item.result, - }, - }); - toolInvocations.push({ - state: "result", - // step: ??? - toolCallId: item.toolCallId, - toolName: item.toolName, - args: item.args || {}, - result: item.result, - }); - } - }); - } else if (typeof assistantMsg.content === "string") { - // Handle plain string content - textContent = assistantMsg.content; - parts.push({ type: "text", text: textContent }); - } - - return { - id: - assistantMsg.id || - `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - createdAt: assistantMsg.createdAt || new Date().toISOString(), - role: "assistant", - content: textContent, - parts, - toolInvocations, - revisionId: Math.random().toString(36).substr(2, 16), - }; -} diff --git a/src/app/api/newsletter/README.md b/src/app/api/newsletter/README.md new file mode 100644 index 00000000..c9b8e79d --- /dev/null +++ b/src/app/api/newsletter/README.md @@ -0,0 +1,53 @@ +# Newsletter API + +This API is responsible for generating and sending personalized crypto newsletters to users via Telegram. + +## Setup + +1. Create a Telegram bot using [@BotFather](https://t.me/botfather) and get your bot token +2. Add the bot token to your environment variables: + ``` + TELEGRAM_BOT_TOKEN=your_telegram_bot_token + ``` +3. Make sure your Supabase database has the `user_telegram_subscriptions` table (see migration file in `src/db/telegram_subscriptions.sql`) + +## How it works + +The API: +1. Fetches all users from the `user_telegram_subscriptions` table where: + - `subscribed_to_newsletter` is true + - `linked` is true + - `telegram_id` is not null +2. For each user, generates a personalized crypto newsletter based on their wallet address +3. Sends the newsletter to their Telegram chat using the bot + +## Usage + +Trigger the newsletter generation and distribution by making a POST request to this endpoint: + +```bash +curl -X POST https://your-domain.com/api/newsletter +``` + +This is typically scheduled to run automatically (e.g., daily or weekly) using a cron job or scheduler. + +## Response + +The API returns a JSON response with the results: + +```json +{ + "success": true, + "results": { + "total": 10, + "successful": 9, + "failed": 1, + "errors": [ + { + "telegram_id": "123456789", + "error": "Error message" + } + ] + } +} +``` \ No newline at end of file diff --git a/src/app/api/newsletter/prompt.ts b/src/app/api/newsletter/prompt.ts new file mode 100644 index 00000000..51723b9a --- /dev/null +++ b/src/app/api/newsletter/prompt.ts @@ -0,0 +1,33 @@ +export const prompt = (address: string) => ` +Before writing the newsletter, use the following data gathering process: +1. First, use the get_hyperliquid_positions function with address "${address}" to retrieve current market positions. +2. Then, for each token identified in the positions, use the get_token_info function to gather detailed information about those tokens. +3. Use this data to inform your analysis and ensure accuracy in the newsletter content. + +Write a personalized, detailed, expert-level altcoin market analysis newsletter (1200-1500 words) specifically tailored for the wallet owner at address "${address}". This is NOT a generic newsletter but a fully personalized analysis based on their actual holdings and market positions. The article should: + + • Begin with a personalized introduction addressing the wallet owner directly, mentioning their specific portfolio composition and current position status from the gathered data. + • Include a summary of how the overall market sentiment relates specifically to THEIR holdings, not just general market trends. + • Focus analysis on the specific 4-6 altcoins that appear in their portfolio or positions data (from the get_hyperliquid_positions results), rather than arbitrary selections. + • For each of the user's altcoins, provide a dedicated section with the following: + • A recap of how their position in this asset has changed since previous market movements, with specific references to their entry points if available. + • A concise technical analysis specifically relevant to their holding period and position size. + • Commentary on recent price movements as they specifically relate to this user's position (profit/loss status, opportunity costs, etc.). + • Incorporation of any relevant news, partnerships, or development milestones that could impact their specific holdings. + • A forward-looking scenario tailored to their position: specific exit strategies, rebalancing recommendations, or hold advisories based on their current exposure. + • Visual cues (describe where you would place colored circles, boxes, or arrows on a chart for clarity, specifically highlighting entry/exit points relevant to their position). + • Where relevant, discuss how historical price behavior or technical indicators for their specific assets are repeating, diverging, or signaling potential moves. + • A risk assessment rating (Low/Medium/High) with brief justification based on the proportion of their portfolio in this asset and current market setup. + • End each section with personalized key takeaways for their position in that coin and specific watch points relevant to their entry/exit needs. + • Include a portfolio recommendation section suggesting potential rebalancing, taking profits, cutting losses, or holding based on their specific positions. + • Maintain a conversational yet authoritative tone, focusing on actionable insights specifically for THEIR portfolio. + • Close the newsletter with a personalized note acknowledging their specific investment strategy evident from their positions and offering to analyze additional tokens they may be considering. + +Format: + • Use clear section headers for each altcoin they hold. + • Employ bullet points or short paragraphs for clarity. + • Address the reader directly throughout the newsletter. + • Keep the tone professional but conversational, as if speaking directly to this specific investor. + • Balance technical analysis with fundamental insights specifically relevant to their holding period and investment size. + +This highly personalized approach should make it clear that this newsletter was created exclusively for the wallet owner at address "${address}" based on their actual market positions and holdings.`; diff --git a/src/app/api/newsletter/route.ts b/src/app/api/newsletter/route.ts new file mode 100644 index 00000000..d5c042bd --- /dev/null +++ b/src/app/api/newsletter/route.ts @@ -0,0 +1,424 @@ +import { NextResponse } from "next/server"; + + + +import { google } from "@ai-sdk/google"; +import { experimental_createMCPClient as createMCPClient, generateText, tool } from "ai"; +import { Telegraf } from "telegraf"; +import { z } from "zod"; + + + +import { createClient } from "@/lib/supabaseClient"; + +import { prompt } from "./prompt"; + +const model = google("gemini-2.5-flash-preview-04-17", { + useSearchGrounding: false, +}); + +const searchEnabledModel = google("gemini-2.5-flash-preview-04-17", { + useSearchGrounding: true, +}); + +const neo_search = tool({ + description: + "Search the web for current information, news, or context about a topic. Use this for general information needs.", + parameters: z.object({ + searchQuery: z.string().describe("The query to search for on the web"), + }), + execute: async ({ searchQuery }) => { + const requestId = `newsletter-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const neoSearchRequestId = `${requestId}-neosearch`; + console.log( + `[${neoSearchRequestId}] 🔍 NeoSearch execute FUNCTION IS BEING CALLED! (Using generateText internally)` + ); + console.log( + `[${neoSearchRequestId}] NeoSearch - Search query:`, + searchQuery + ); + + // Use the separate search-enabled model for this tool + const searchResponse = await generateText({ + model: searchEnabledModel, + prompt: searchQuery, + }); + + const text = searchResponse.text; + const metadata = searchResponse.providerMetadata; + const googleMetadata = metadata?.google; + + console.log( + `[${neoSearchRequestId}] NeoSearch successful for query:`, + searchQuery + ); + return { + searchResults: text, + sources: googleMetadata?.sources || [], + metadata: { + searchQuery, + timestamp: new Date().toISOString(), + }, + }; + }, +}); + +export async function POST() { + const requestId = `newsletter-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + console.log(`[${requestId}] Starting newsletter generation and dispatch`); + + if (!process.env.TELEGRAM_BOT_TOKEN) { + console.error(`[${requestId}] TELEGRAM_BOT_TOKEN is missing`); + return NextResponse.json( + { error: "TELEGRAM_BOT_TOKEN is missing" }, + { status: 500 } + ); + } + + try { + const supabase = createClient(); + + // Fetch all users who subscribed to the newsletter + const { data: subscribers, error: fetchError } = await supabase + .from("user_telegram_subscriptions") + .select("telegram_id, user_address") + .eq("subscribed_to_newsletter", true) + .eq("linked", true) + .not("telegram_id", "is", null); + + if (fetchError) { + console.error(`[${requestId}] Error fetching subscribers:`, fetchError); + return NextResponse.json( + { error: "Failed to fetch subscribers" }, + { status: 500 } + ); + } + + console.log(`[${requestId}] Found ${subscribers.length} subscribers`); + + if (subscribers.length === 0) { + return NextResponse.json({ + success: true, + message: "No subscribers found", + }); + } + + // Initialize Telegram bot + const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN); + + const mcpClient = await createMCPClient({ + transport: { + type: "sse", + url: process.env.MATRIX_MCP_URL || "", + headers: { + Authorization: `Bearer ${process.env.MATRIX_MCP_API_KEY}`, + "x-api-key": process.env.MATRIX_MCP_API_KEY || "", + }, + }, + }); + + const tools = await mcpClient.tools({ + schemas: { + get_token_info: { + description: + "Get token information including price, market cap, volume, metadata, and optionally historical data", + parameters: z.object({ + query: z + .string() + .describe( + "Token symbol, name, or contract address to search for" + ), + type: z + .enum(["symbol", "name", "address"]) + .optional() + .describe( + 'Type of search to perform. Options are "symbol", "name", or "address". If not provided, will auto-detect based on query' + ), + historical_days: z + .number() + .int() + .positive() + .optional() + .describe( + "Number of past days to fetch historical price data for (e.g., 7, 30)" + ), + }), + }, + get_hyperliquid_positions: { + description: + "Retrieves the full clearinghouse state (positions and margin) for a given address on Hyperliquid", + parameters: z.object({ + address: z.string().describe("The address to get positions for"), + }), + }, + }, + }); + + // Track successful and failed deliveries + const results = { + successful: 0, + failed: 0, + errors: [] as Array<{ telegram_id: string; error: string }>, + }; + + // Process each subscriber + for (const subscriber of subscribers) { + try { + console.log( + `[${requestId}] Generating newsletter for user ${subscriber.user_address}` + ); + + // Generate newsletter content + const result = await generateText({ + model, + prompt: prompt(subscriber.user_address), + tools: { ...tools, neo_search }, + maxSteps: 50, + }); + + // Format newsletter + const newsletterDate = new Date().toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + + try { + // Format newsletter content with some basic styling + const title = "🤖 Matrix AI - Personalized Crypto Analysis 📊"; + const subtitle = "Based on your wallet positions and market data"; + const dateFormatted = `📅 ${newsletterDate}`; + const header = `${title}\n${subtitle}\n${dateFormatted}\n\n`; + + // Clean up the text content + const cleanText = result.text + .replace(/```[\s\S]*?```/g, "") // Remove code blocks + .replace(/`([^`]+)`/g, "$1") // Remove inline code format + .replace(/\*\*(.*?)\*\*/g, "$1") // Remove bold format + .replace(/\*(.*?)\*/g, "$1") // Remove italic format + .replace(/\[(.*?)\]\((.*?)\)/g, "$1 ($2)"); // Convert links to text + + // Add some crypto-related emojis to headings to improve readability and structure + const formattedText = cleanText + // Main section headers for coins + .replace(/(?:^|\n)([A-Z][A-Z\s]+)(?::|\n)/g, "\n\n💼 $1:") + + // Standard market metrics + .replace(/(?:^|\n)Price:?/gim, "\n💰 Price:") + .replace(/(?:^|\n)Volume:?/gim, "\n📈 Volume:") + .replace(/(?:^|\n)Market\s+Cap:?/gim, "\n💎 Market Cap:") + .replace(/(?:^|\n)Trend:?/gim, "\n📊 Trend:") + + // Analysis sections + .replace(/(?:^|\n)Analysis:?/gim, "\n🔍 Analysis:") + .replace( + /(?:^|\n)Technical Analysis:?/gim, + "\n📐 Technical Analysis:" + ) + .replace( + /(?:^|\n)Fundamental Analysis:?/gim, + "\n🏛️ Fundamental Analysis:" + ) + .replace(/(?:^|\n)Recommendation:?/gim, "\n🚨 Recommendation:") + .replace(/(?:^|\n)Positions?:?/gim, "\n📝 Positions:") + + // Risk and portfolio sections + .replace(/(?:^|\n)Risk Assessment:?/gim, "\n⚠️ Risk Assessment:") + .replace(/(?:^|\n)Risk:? (Low|Medium|High)/gim, "\n⚠️ Risk: $1") + .replace( + /(?:^|\n)Portfolio Recommendation:?/gim, + "\n📊 Portfolio Recommendation:" + ) + .replace(/(?:^|\n)Rebalancing:?/gim, "\n⚖️ Rebalancing:") + + // Key sections from the prompt + .replace(/(?:^|\n)Key Takeaways:?/gim, "\n🔑 Key Takeaways:") + .replace(/(?:^|\n)Watch Points:?/gim, "\n👀 Watch Points:") + .replace(/(?:^|\n)Entry Points?:?/gim, "\n⤵️ Entry Point:") + .replace(/(?:^|\n)Exit Strategies?:?/gim, "\n⤴️ Exit Strategy:") + .replace(/(?:^|\n)Summary:?/gim, "\n📋 Summary:") + + // Bullets and numbered lists formatting + .replace(/(?:^|\n)• /g, "\n• ") + .replace(/(?:^|\n)(\d+)\. /g, "\n$1. "); + + // Split message if it's too long (Telegram has a 4096 character limit) + const MAX_MESSAGE_LENGTH = 4000; // Leave some buffer + + if (header.length + formattedText.length <= MAX_MESSAGE_LENGTH) { + // Can send as a single message + await bot.telegram.sendMessage( + subscriber.telegram_id, + header + formattedText + ); + console.log( + `[${requestId}] Successfully sent newsletter to Telegram ID: ${subscriber.telegram_id}` + ); + results.successful++; + } else { + // Need to split the message into multiple parts + console.log( + `[${requestId}] Message too long, splitting into multiple parts for ${subscriber.telegram_id}` + ); + + // Send the header with first part + const parts = []; + let remainingText = formattedText; + let partNumber = 1; + + // First message with header + const firstPartLength = MAX_MESSAGE_LENGTH - header.length - 30; // Extra buffer for part indicator + let firstPart = remainingText.substring(0, firstPartLength); + // Try to split at a paragraph break if possible + const lastNewline = firstPart.lastIndexOf("\n\n"); + if (lastNewline > firstPartLength * 0.7) { + // Only use paragraph break if at least 70% through + firstPart = firstPart.substring(0, lastNewline); + } + + const firstMessage = `${header}${firstPart}\n\n(Part 1/${Math.ceil(formattedText.length / MAX_MESSAGE_LENGTH)})`; + parts.push(firstMessage); + remainingText = remainingText.substring(firstPart.length); + + // Process remaining parts + while (remainingText.length > 0) { + partNumber++; + const partPrefix = `🤖 Matrix AI Update (Part ${partNumber}/${Math.ceil(formattedText.length / MAX_MESSAGE_LENGTH)})\n\n`; + const partMaxLength = MAX_MESSAGE_LENGTH - partPrefix.length; + let part = remainingText.substring(0, partMaxLength); + + // Try to split at paragraph break + const newlinePos = part.lastIndexOf("\n\n"); + if (newlinePos > partMaxLength * 0.7) { + part = part.substring(0, newlinePos); + } + + parts.push(`${partPrefix}${part}`); + remainingText = remainingText.substring(part.length); + } + + // Send all parts + for (const part of parts) { + await bot.telegram.sendMessage(subscriber.telegram_id, part); + // Add a small delay between messages to avoid rate limits + await new Promise(resolve => setTimeout(resolve, 100)); + } + + console.log( + `[${requestId}] Successfully sent ${parts.length} part newsletter to Telegram ID: ${subscriber.telegram_id}` + ); + results.successful++; + } + } catch (telegramError) { + console.error( + `[${requestId}] Error sending to Telegram ID ${subscriber.telegram_id}:`, + telegramError + ); + + // If that fails, fall back to a very simple plaintext message + try { + console.log( + `[${requestId}] Trying fallback plain text message for ${subscriber.telegram_id}` + ); + + // Create a simpler version but still with some structure + const plainTitle = "Matrix AI - Personalized Crypto Analysis"; + const plainSubtitle = + "Based on your wallet positions and market data"; + const plainHeader = `${plainTitle}\n${plainSubtitle}\n${newsletterDate}\n\n`; + + // Basic formatting for the plain text version + const plainText = result.text + .replace(/```[\s\S]*?```/g, "") // Remove code blocks + .replace(/`([^`]+)`/g, "$1") // Remove inline code + .replace(/\*\*(.*?)\*\*/g, "$1") // Remove bold format + .replace(/\*(.*?)\*/g, "$1") // Remove italic format + .replace(/\[(.*?)\]\((.*?)\)/g, "$1 ($2)") // Convert links + // Add some minimal formatting + .replace(/(?:^|\n)([A-Z][A-Z\s]+)(?::|\n)/g, "\n\n$1:") // Format section headers + .replace(/(?:^|\n)• /g, "\n• ") // Format bullet points + .replace(/(?:^|\n)(\d+)\. /g, "\n$1. "); // Format numbered lists + + // Split into multiple messages if needed + const MAX_LENGTH = 4000; + if (plainHeader.length + plainText.length <= MAX_LENGTH) { + await bot.telegram.sendMessage( + subscriber.telegram_id, + plainHeader + plainText + ); + } else { + // First part with header + let remaining = plainText; + const firstPartText = remaining.substring( + 0, + MAX_LENGTH - plainHeader.length + ); + await bot.telegram.sendMessage( + subscriber.telegram_id, + plainHeader + firstPartText + ); + remaining = remaining.substring(firstPartText.length); + + // Send remaining parts + while (remaining.length > 0) { + const part = remaining.substring(0, MAX_LENGTH); + await bot.telegram.sendMessage(subscriber.telegram_id, part); + remaining = remaining.substring(part.length); + // Small delay to avoid rate limits + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + console.log( + `[${requestId}] Successfully sent plain text newsletter to Telegram ID: ${subscriber.telegram_id}` + ); + results.successful++; + } catch (fallbackError) { + console.error( + `[${requestId}] Fallback also failed for Telegram ID ${subscriber.telegram_id}:`, + fallbackError + ); + results.failed++; + results.errors.push({ + telegram_id: subscriber.telegram_id, + error: + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError), + }); + } + } + } catch (error) { + console.error( + `[${requestId}] Error processing subscriber ${subscriber.user_address}:`, + error + ); + results.failed++; + results.errors.push({ + telegram_id: subscriber.telegram_id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return NextResponse.json({ + success: true, + results: { + total: subscribers.length, + successful: results.successful, + failed: results.failed, + errors: results.errors.length > 0 ? results.errors : undefined, + }, + }); + } catch (error) { + console.error(`[${requestId}] Error in newsletter generation:`, error); + return NextResponse.json( + { + error: "Failed to generate and send newsletters", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/components/chat/example-queries.tsx b/src/components/chat/example-queries.tsx index 9ec51657..6d39bd47 100644 --- a/src/components/chat/example-queries.tsx +++ b/src/components/chat/example-queries.tsx @@ -3,14 +3,24 @@ import * as React from "react"; import { useEffect, useRef, useState } from "react"; + + import { motion } from "framer-motion"; import { ArrowRight, BookOpen, Database, Info, X } from "lucide-react"; + + import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; + + import { cn } from "@/lib/utils"; + + + + interface ExampleQueriesProps { onSelect: (query: string) => void; activeMode?: "morpheus" | "sentinel"; @@ -58,6 +68,7 @@ export function ExampleQueries({ "Has the $ETH recently formed a bullish pattern on the weekly chart?", ], sentinel: [ + "Link my Telegram account and subscribe to the newsletter.", "What's in my Wallet?", "What open perps positions do I have?", "What lending markets are available for supplying USDC?", @@ -359,4 +370,4 @@ export function ExampleQueries({ )} ); -} +} \ No newline at end of file diff --git a/src/lib/blockchainConfig.ts b/src/lib/blockchainConfig.ts new file mode 100644 index 00000000..27159e48 --- /dev/null +++ b/src/lib/blockchainConfig.ts @@ -0,0 +1,42 @@ +import { EVM, createConfig } from "@lifi/sdk"; +import { createWalletClient, http } from "viem"; +import type { Chain as vChain } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base, mode } from "viem/chains"; + +// Initialize account with dummy key for testing +const account = privateKeyToAccount( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +); + +// Supported chains +const chains = [base, mode]; // Add other chains as needed + +// Create wallet client +export const client = createWalletClient({ + account, + chain: base, + transport: http(), +}); + +// Initialize LiFi config +export const initializeBlockchainConfig = () => { + createConfig({ + integrator: "ionic", + providers: [ + EVM({ + getWalletClient: async () => client, + switchChain: async (chainId: number) => + // Switch chain by creating a new wallet client + createWalletClient({ + account, + chain: chains.find(chain => chain.id == chainId) as vChain, + transport: http(), + }), + }), + ], + }); +}; + +// Export chains array for potential reuse +export { chains }; diff --git a/src/lib/chatStorageManager.ts b/src/lib/chatStorageManager.ts new file mode 100644 index 00000000..99948f5b --- /dev/null +++ b/src/lib/chatStorageManager.ts @@ -0,0 +1,64 @@ +import { SupabaseClient } from "@supabase/supabase-js"; + +import { UIMessage } from "@/app/api/chat/tools/types"; + +import { generateConversationTitle } from "./titleGenerator"; + +/** + * Saves chat data to the database + */ +export async function saveChatToDatabase( + supabaseClient: SupabaseClient, + id: string, + walletAddress: string, + messages: UIMessage[], + isFavorite: boolean = false, + requestId: string +): Promise<{ success: boolean; error?: any }> { + try { + const title = await generateConversationTitle(messages || []); + const userMessage = messages?.find((m: UIMessage) => m.role === "user"); + const assistantMessage = messages + ?.filter((m: UIMessage) => m.role === "assistant") + .pop(); + + const saveData = { + id, + wallet_address: walletAddress, + label: title, + prompt: userMessage?.content || "", + response: + typeof assistantMessage?.content === "string" + ? assistantMessage.content + : "Processing...", + messages: messages || [], // Save the full original messages + is_favorite: isFavorite, + }; + + console.log(`[${requestId}] Save data prepared:`, { + id: saveData.id, + wallet_address: saveData.wallet_address, + title_length: saveData.label.length, + message_count: saveData.messages.length, + is_favorite: saveData.is_favorite, + }); + + console.log(`[${requestId}] Executing upsert to saved_chats table...`); + const { error } = await supabaseClient + .from("saved_chats") + .upsert([saveData], { + onConflict: "id", + }); + + if (error) { + console.error(`[${requestId}] Save error:`, error); + return { success: false, error }; + } + + console.log(`[${requestId}] Chat saved successfully`); + return { success: true }; + } catch (error) { + console.error(`[${requestId}] Database error:`, error); + return { success: false, error }; + } +} diff --git a/src/lib/responseFormatter.ts b/src/lib/responseFormatter.ts new file mode 100644 index 00000000..029d5b8c --- /dev/null +++ b/src/lib/responseFormatter.ts @@ -0,0 +1,71 @@ +import { UIMessage } from "@/app/api/chat/tools/types"; + +/** + * Formats the AI response object into a UI-compatible message object + */ +export function formatResponseToObject(response: { + messages: any[]; +}): UIMessage { + const assistantMsg = response.messages[response.messages.length - 1]; + if (!assistantMsg || assistantMsg.role !== "assistant") { + console.warn( + "formatResponseToObject: Could not find valid assistant message in response. Returning placeholder." + ); + const errorText = "Error: Could not format response."; + return { + id: `msg-error-${Date.now()}`, + role: "assistant", + content: errorText, + parts: [{ type: "text", text: errorText }], + }; + } + + let textContent = ""; + const parts: any[] = []; + const toolInvocations: any[] = []; + + if (Array.isArray(assistantMsg.content)) { + assistantMsg.content.forEach((item: any) => { + if (item.type === "text") { + textContent += item.text; + parts.push({ type: "text", text: item.text }); + } else if (item.type === "tool-call") { + // Handle tool calls if needed + } else if (item.type === "tool-result") { + parts.push({ + type: "tool-invocation", + toolInvocation: { + state: "result", + toolCallId: item.toolCallId, + toolName: item.toolName, + args: item.args || {}, + result: item.result, + }, + }); + toolInvocations.push({ + state: "result", + toolCallId: item.toolCallId, + toolName: item.toolName, + args: item.args || {}, + result: item.result, + }); + } + }); + } else if (typeof assistantMsg.content === "string") { + // Handle plain string content + textContent = assistantMsg.content; + parts.push({ type: "text", text: textContent }); + } + + return { + id: + assistantMsg.id || + `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + createdAt: assistantMsg.createdAt || new Date().toISOString(), + role: "assistant", + content: textContent, + parts, + toolInvocations, + revisionId: Math.random().toString(36).substr(2, 16), + }; +} diff --git a/src/lib/titleGenerator.ts b/src/lib/titleGenerator.ts new file mode 100644 index 00000000..0c7617a6 --- /dev/null +++ b/src/lib/titleGenerator.ts @@ -0,0 +1,53 @@ +import { google } from "@ai-sdk/google"; +import { generateText } from "ai"; + +import { UIMessage } from "@/app/api/chat/tools/types"; + +// Cache for generated titles +const titleCache = new Map(); + +/** + * Generates a conversation title based on the first user message + */ +export async function generateConversationTitle( + messages: UIMessage[] +): Promise { + try { + const firstUserMessage = messages.find( + (m: UIMessage) => m.role === "user" && m.content?.trim().length > 0 + ); + if (!firstUserMessage) return "New Conversation"; // Handle case with no user message + + const cacheKey = `${firstUserMessage.content.slice(0, 100)}|${firstUserMessage.id}`; + if (titleCache.has(cacheKey)) { + return titleCache.get(cacheKey)!; + } + const { text: title } = await generateText({ + model: google("gemini-2.0-flash-001"), + system: `Generate a concise 4-8 word title for this user request. Focus on the main action, asset, or topic. Examples: "Check ETH Balance", "Swap USDC to WETH", "Bitcoin Price Analysis", "Ionic Lend Position". Respond ONLY with the title text, no quotes or punctuation.`, + messages: [ + { + role: "user", + content: `Request: ${firstUserMessage.content.slice(0, 300)}`, + }, + ], + maxTokens: 20, + }); + + const cleanTitle = title.trim().replace(/["'.]/g, "").slice(0, 80); + + const finalTitle = cleanTitle || firstUserMessage.content.slice(0, 80); + + // Cache and return + titleCache.set(cacheKey, finalTitle); + return finalTitle; + } catch (error) { + console.error("Title generation failed:", error); + // Fallback logic + const firstMessageContent = messages[0]?.content; + return typeof firstMessageContent === "string" + ? firstMessageContent.slice(0, 80).trim() + + (firstMessageContent.length > 80 ? "..." : "") + : "New Conversation"; + } +}