@aj-archipelago/cortex
Version:
Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.
1 lines • 697 kB
Plain Text
"<CHAT_HISTORY>\n[{\"role\":\"user\",\"content\":[\"Release notes for this?\\n\\ndiff --git a/.github/workflows/labeeb-workers-main-AutoDeployTrigger-80f2a7e2-7b85-4d05-974c-f2699ea43214.yml b/.github/workflows/labeeb-workers-main-AutoDeployTrigger-80f2a7e2-7b85-4d05-974c-f2699ea43214.yml\\ndeleted file mode 100644\\nindex ed46bb3..0000000\\n--- a/.github/workflows/labeeb-workers-main-AutoDeployTrigger-80f2a7e2-7b85-4d05-974c-f2699ea43214.yml\\n+++ /dev/null\\n@@ -1,48 +0,0 @@\\n-name: Trigger auto deployment for labeeb-workers-main\\n-\\n-# When this action will be executed\\n-on:\\n- # Automatically trigger it when detected changes in repo\\n- push:\\n- branches: \\n- [ main ]\\n- paths:\\n- - '**'\\n- - '.github/workflows/labeeb-workers-main-AutoDeployTrigger-80f2a7e2-7b85-4d05-974c-f2699ea43214.yml'\\n-\\n- # Allow manual trigger \\n- workflow_dispatch: \\n-\\n-jobs:\\n- build-and-deploy-workers:\\n- runs-on: ubuntu-latest\\n- permissions: \\n- id-token: write #This is required for requesting the OIDC JWT Token\\n- contents: read #Required when GH token is used to authenticate with private repo\\n-\\n- steps:\\n- - name: Checkout to the branch\\n- uses: actions/checkout@v2\\n-\\n- - name: Azure Login\\n- uses: azure/login@v1\\n- with:\\n- client-id: ${{ secrets.LABEEBWORKERSMAIN_AZURE_CLIENT_ID }}\\n- tenant-id: ${{ secrets.LABEEBWORKERSMAIN_AZURE_TENANT_ID }}\\n- subscription-id: ${{ secrets.LABEEBWORKERSMAIN_AZURE_SUBSCRIPTION_ID }}\\n-\\n- - name: Build and push container image to registry\\n- uses: azure/container-apps-deploy-action@v2\\n- with:\\n- appSourcePath: ${{ github.workspace }}\\n- dockerFilePath: Dockerfile.worker\\n- registryUrl: archipelagoairegistry.azurecr.io\\n- registryUsername: ${{ secrets.LABEEBWORKERSMAIN_REGISTRY_USERNAME }}\\n- registryPassword: ${{ secrets.LABEEBWORKERSMAIN_REGISTRY_PASSWORD }}\\n- containerAppName: labeeb-workers-main\\n- resourceGroup: Archipelago-ML-Experimentation\\n- imageToBuild: archipelagoairegistry.azurecr.io/labeeb-workers-main:${{ github.sha }}\\n- _buildArgumentsKey_: |\\n- _buildArgumentsValues_\\n-\\n-\\ndiff --git a/.gitignore b/.gitignore\\nindex a71a12f..3138f16 100644\\n--- a/.gitignore\\n+++ b/.gitignore\\n@@ -30,3 +30,4 @@ public/app/\\n src/locales/\\n dump.rdb\\n *.code-workspace\\n+.cursorrules\\n\\\\ No newline at end of file\\ndiff --git a/@/components/ui/alert-dialog.jsx b/@/components/ui/alert-dialog.jsx\\nnew file mode 100644\\nindex 0000000..1a0edf7\\n--- /dev/null\\n+++ b/@/components/ui/alert-dialog.jsx\\n@@ -0,0 +1,122 @@\\n+\\\"use client\\\";\\n+\\n+import * as React from \\\"react\\\";\\n+import * as AlertDialogPrimitive from \\\"@radix-ui/react-alert-dialog\\\";\\n+\\n+import { cn } from \\\"@/lib/utils\\\";\\n+import { buttonVariants } from \\\"@/components/ui/button\\\";\\n+\\n+const AlertDialog = AlertDialogPrimitive.Root;\\n+\\n+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;\\n+\\n+const AlertDialogPortal = AlertDialogPrimitive.Portal;\\n+\\n+const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Overlay\\n+ className={cn(\\n+ \\\"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ ref={ref}\\n+ />\\n+));\\n+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;\\n+\\n+const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPortal>\\n+ <AlertDialogOverlay />\\n+ <AlertDialogPrimitive.Content\\n+ ref={ref}\\n+ className={cn(\\n+ \\\"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-gray-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dark:border-gray-800 dark:bg-gray-950\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+ </AlertDialogPortal>\\n+));\\n+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;\\n+\\n+const AlertDialogHeader = ({ className, ...props }) => (\\n+ <div\\n+ className={cn(\\n+ \\\"flex flex-col space-y-2 text-center sm:text-left\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+);\\n+AlertDialogHeader.displayName = \\\"AlertDialogHeader\\\";\\n+\\n+const AlertDialogFooter = ({ className, ...props }) => (\\n+ <div\\n+ className={cn(\\n+ \\\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+);\\n+AlertDialogFooter.displayName = \\\"AlertDialogFooter\\\";\\n+\\n+const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Title\\n+ ref={ref}\\n+ className={cn(\\\"text-lg font-semibold\\\", className)}\\n+ {...props}\\n+ />\\n+));\\n+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;\\n+\\n+const AlertDialogDescription = React.forwardRef(\\n+ ({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Description\\n+ ref={ref}\\n+ className={cn(\\n+ \\\"text-sm text-gray-500 dark:text-gray-400\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+ ),\\n+);\\n+AlertDialogDescription.displayName =\\n+ AlertDialogPrimitive.Description.displayName;\\n+\\n+const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Action\\n+ ref={ref}\\n+ className={cn(buttonVariants(), className)}\\n+ {...props}\\n+ />\\n+));\\n+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;\\n+\\n+const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Cancel\\n+ ref={ref}\\n+ className={cn(\\n+ buttonVariants({ variant: \\\"outline\\\" }),\\n+ \\\"mt-2 sm:mt-0\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+));\\n+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;\\n+\\n+export {\\n+ AlertDialog,\\n+ AlertDialogPortal,\\n+ AlertDialogOverlay,\\n+ AlertDialogTrigger,\\n+ AlertDialogContent,\\n+ AlertDialogHeader,\\n+ AlertDialogFooter,\\n+ AlertDialogTitle,\\n+ AlertDialogDescription,\\n+ AlertDialogAction,\\n+ AlertDialogCancel,\\n+};\\ndiff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js\\nnew file mode 100644\\nindex 0000000..f053ebf\\n--- /dev/null\\n+++ b/__mocks__/styleMock.js\\n@@ -0,0 +1 @@\\n+module.exports = {};\\ndiff --git a/app.config/config/data/taxonomySets.js b/app.config/config/data/taxonomySets.js\\nindex 82bafa9..1ef9412 100644\\n--- a/app.config/config/data/taxonomySets.js\\n+++ b/app.config/config/data/taxonomySets.js\\n@@ -62,7 +62,7 @@ export async function initializeTaxonomies() {\\n // will include the same file with two paths:\\n // ./filename.json and <absolute-path>/filename.json\\n if (dedupedFileNames.includes(filenameOnly)) {\\n- return;\\n+ return null;\\n }\\n \\n const setName = filename.slice(2, -5); // Remove './' and '.json' from the file name\\ndiff --git a/app.config/config/index.js b/app.config/config/index.js\\nindex eb7facb..30c326d 100644\\n--- a/app.config/config/index.js\\n+++ b/app.config/config/index.js\\n@@ -14,7 +14,7 @@ const cortexURLs = {\\n // The entire Labeeb application can be configured here\\n // Note that all assets and locales are copied to the public/app and src/locales directories respectively\\n // by the prebuild.js script\\n-export default {\\n+const config = {\\n global: {\\n siteTitle: \\\"Labeeb\\\",\\n getLogo: (language) =>\\n@@ -77,3 +77,5 @@ export default {\\n provider: \\\"entra\\\",\\n },\\n };\\n+\\n+export default config;\\ndiff --git a/app.config/config/transcribe/TranscribeUrlConstants.js b/app.config/config/transcribe/TranscribeUrlConstants.js\\nindex db70436..e5e824c 100644\\n--- a/app.config/config/transcribe/TranscribeUrlConstants.js\\n+++ b/app.config/config/transcribe/TranscribeUrlConstants.js\\n@@ -1,3 +1,5 @@\\n+import { isYoutubeUrl } from \\\"../../../src/utils/urlUtils\\\";\\n+\\n export const AJE = \\\"665003303001\\\";\\n export const AJA = \\\"665001584001\\\";\\n export const getAxisUrl = (accountId, searchQuery) =>\\n@@ -9,6 +11,40 @@ export const fetchUrlSource = async (url) => {\\n );\\n if (!response.ok) {\\n const data = await response.json();\\n+ if (data.error === \\\"Unsupported YouTube channel\\\" && isYoutubeUrl(url)) {\\n+ // Convert YouTube URL to embed URL\\n+ const videoId = url.match(/(?:v=|\\\\/)([\\\\w-]{11})(?:\\\\?|$|&)/)?.[1];\\n+ const embedUrl = videoId\\n+ ? `https://www.youtube.com/embed/${videoId}`\\n+ : url;\\n+\\n+ // Fetch video title using oEmbed\\n+ let videoTitle = \\\"YouTube Video (External)\\\";\\n+ try {\\n+ const oembedResponse = await fetch(\\n+ `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,\\n+ );\\n+ if (oembedResponse.ok) {\\n+ const oembedData = await oembedResponse.json();\\n+ videoTitle = oembedData.title;\\n+ }\\n+ } catch (e) {\\n+ console.warn(\\\"Failed to fetch YouTube video title:\\\", e);\\n+ }\\n+\\n+ return {\\n+ results: [\\n+ {\\n+ name: videoTitle,\\n+ similarity: 1,\\n+ videoUrl: embedUrl,\\n+ url: url,\\n+ isYouTube: true,\\n+ fromExternalChannel: true,\\n+ },\\n+ ],\\n+ };\\n+ }\\n throw new Error(\\n formatErrorMessage(data.error) || \\\"Network response was not ok\\\",\\n );\\ndiff --git a/app.config/locales/ar.json b/app.config/locales/ar.json\\nindex 8550558..3966c3f 100644\\n--- a/app.config/locales/ar.json\\n+++ b/app.config/locales/ar.json\\n@@ -497,5 +497,31 @@\\n \\\"Download .txt\\\": \\\"تنزيل بتنسيق TXT\\\",\\n \\\"Taxonomy\\\": \\\"التصنيف\\\",\\n \\\"Transcript\\\": \\\"النص المنسوخ\\\",\\n- \\\"{{name}}: {{language}} Translation\\\": \\\"{{name}}: ترجمة {{language}}\\\"\\n+ \\\"{{name}}: {{language}} Translation\\\": \\\"{{name}}: ترجمة {{language}}\\\",\\n+ \\\"Processing media...\\\": \\\"جاري معالجة الوسائط...\\\",\\n+ \\\"Transcription type\\\": \\\"نوع التنسيق\\\",\\n+ \\\"Memory backup\\\": \\\"نسخة احتياطية للذاكرة\\\",\\n+ \\\"Download memory backup\\\": \\\"تنزيل نسخة احتياطية للذاكرة\\\",\\n+ \\\"Upload memory from backup\\\": \\\"تحميل الذاكرة من النسخة الاحتياطية\\\",\\n+ \\\"Failed to read the file. Please try again.\\\": \\\"فشل في قراءة الملف. يرجى المحاولة مرة أخرى.\\\",\\n+ \\\"Failed to parse memory file. Please ensure it is a valid JSON file with the correct memory structure.\\\": \\\"فشل في تحليل ملف الذاكرة. يرجى التأكد من أنه ملف JSON صالح بهيكل الذاكرة الصحيح.\\\",\\n+ \\\"Invalid memory file format\\\": \\\"تنسيق ملف الذاكرة غير صالح\\\",\\n+ \\\"Enable streaming responses\\\": \\\"تفعيل الاستجابات المنسية\\\",\\n+ \\\"{{from}} to {{to}}\\\": \\\"{{from}} إلى {{to}}\\\",\\n+ \\\"Video translation\\\": \\\"ترجمة الفيديو\\\",\\n+ \\\"In progress\\\": \\\"قيد التنفيذ\\\",\\n+ \\\"Completed\\\": \\\"منجز\\\",\\n+ \\\"Failed\\\": \\\"فشل\\\",\\n+ \\\"View all\\\": \\\"عرض الكل\\\",\\n+ \\\"No recent or active notifications\\\": \\\"لا يوجد إشعارات مفعلة\\\",\\n+ \\\"View history\\\": \\\"عرض التاريخ\\\",\\n+ \\\"All notifications\\\": \\\"جميع الإشعارات\\\",\\n+ \\\"Transcript not looking right?\\\": \\\"النص المنسوخ لا يبدو صحيحًا؟\\\",\\n+ \\\"Transcribe again using an alternate model\\\": \\\"تنسيق مرة أخرى باستخدام نموذج مختلف\\\",\\n+ \\\"Re-transcribing\\\": \\\"إعادة التنسيق\\\",\\n+ \\\"Add audio track\\\": \\\"إضافة صوت\\\",\\n+ \\\"Transcribing... This may take a few minutes.\\\": \\\"جاري التنسيق... قد يستغرق هذا بضع دقائق.\\\",\\n+ \\\"Auto-transcribing\\\": \\\"تنسيق تلقائي\\\",\\n+ \\\"Edit title\\\": \\\"تعديل العنوان\\\",\\n+ \\\"Delete chat\\\": \\\"حذف الدردشة\\\"\\n }\\ndiff --git a/app/api/azure-video-translate/route.js b/app/api/azure-video-translate/route.js\\nnew file mode 100644\\nindex 0000000..1bb3a97\\n--- /dev/null\\n+++ b/app/api/azure-video-translate/route.js\\n@@ -0,0 +1,107 @@\\n+import { NextResponse } from \\\"next/server\\\";\\n+import { Queue } from \\\"bullmq\\\";\\n+import Redis from \\\"ioredis\\\";\\n+import { AZURE_VIDEO_TRANSLATE } from \\\"../../../src/graphql\\\";\\n+import { getClient } from \\\"../../../src/graphql\\\";\\n+import RequestProgress from \\\"../models/request-progress.mjs\\\";\\n+import { getCurrentUser } from \\\"../utils/auth\\\";\\n+\\n+const connection = new Redis(\\n+ process.env.REDIS_CONNECTION_STRING || \\\"redis://localhost:6379\\\",\\n+ {\\n+ maxRetriesPerRequest: null,\\n+ },\\n+);\\n+\\n+const requestProgressQueue = new Queue(\\\"request-progress\\\", {\\n+ connection,\\n+});\\n+\\n+export async function POST(req) {\\n+ try {\\n+ const body = await req.json();\\n+ const { sourceLocale, targetLocale, targetLocaleLabel, url } = body;\\n+\\n+ console.log(\\\"Starting video translation request:\\\", {\\n+ sourceLocale,\\n+ targetLocale,\\n+ targetLocaleLabel,\\n+ url,\\n+ });\\n+\\n+ // Initial GraphQL query to start the translation\\n+ const { data } = await getClient().query({\\n+ query: AZURE_VIDEO_TRANSLATE,\\n+ variables: {\\n+ mode: \\\"uploadvideooraudiofileandcreatetranslation\\\",\\n+ sourcelocale: sourceLocale,\\n+ targetlocale: targetLocale,\\n+ sourcevideooraudiofilepath: url,\\n+ stream: true,\\n+ },\\n+ fetchPolicy: \\\"no-cache\\\",\\n+ });\\n+\\n+ const requestId = data.azure_video_translate.result;\\n+\\n+ console.log(\\\"Got requestId from Azure:\\\", requestId);\\n+\\n+ // Get current user\\n+ const user = await getCurrentUser();\\n+\\n+ // Create initial progress record\\n+ await RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ {\\n+ owner: user._id,\\n+ type: \\\"video-translate\\\",\\n+ status: \\\"in_progress\\\",\\n+ metadata: {\\n+ sourceLocale,\\n+ targetLocale,\\n+ url,\\n+ },\\n+ },\\n+ {\\n+ new: true,\\n+ upsert: true,\\n+ },\\n+ );\\n+\\n+ // Add job to queue\\n+ const job = await requestProgressQueue.add(\\n+ \\\"request-progress\\\",\\n+ {\\n+ requestId,\\n+ type: \\\"video-translate\\\",\\n+ userId: user._id,\\n+ metadata: {\\n+ sourceLocale,\\n+ targetLocale,\\n+ targetLocaleLabel,\\n+ url,\\n+ },\\n+ },\\n+ {\\n+ timeout: 5 * 60 * 1000,\\n+ removeOnComplete: {\\n+ age: 24 * 3600,\\n+ count: 1000,\\n+ },\\n+ removeOnFail: {\\n+ age: 24 * 3600,\\n+ },\\n+ },\\n+ );\\n+\\n+ console.log(\\\"Added job to queue:\\\", job.id);\\n+\\n+ return NextResponse.json({\\n+ requestId,\\n+ jobId: job.id,\\n+ });\\n+ } catch (error) {\\n+ console.error(\\\"Azure video translate error:\\\", error);\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\ndiff --git a/app/api/cancel-request/route.js b/app/api/cancel-request/route.js\\nnew file mode 100644\\nindex 0000000..ddcc3d5\\n--- /dev/null\\n+++ b/app/api/cancel-request/route.js\\n@@ -0,0 +1,53 @@\\n+import { NextResponse } from \\\"next/server\\\";\\n+import { Queue } from \\\"bullmq\\\";\\n+import Redis from \\\"ioredis\\\";\\n+import RequestProgress from \\\"../models/request-progress.mjs\\\";\\n+import { getCurrentUser } from \\\"../utils/auth\\\";\\n+\\n+const connection = new Redis(\\n+ process.env.REDIS_CONNECTION_STRING || \\\"redis://localhost:6379\\\",\\n+ {\\n+ maxRetriesPerRequest: null,\\n+ },\\n+);\\n+\\n+const requestProgressQueue = new Queue(\\\"request-progress\\\", { connection });\\n+\\n+export async function POST(req) {\\n+ try {\\n+ const { requestId } = await req.json();\\n+ const user = await getCurrentUser();\\n+\\n+ // Find the request and verify ownership\\n+ const request = await RequestProgress.findOne({\\n+ requestId,\\n+ owner: user._id,\\n+ });\\n+\\n+ if (!request) {\\n+ return NextResponse.json(\\n+ { error: \\\"Request not found\\\" },\\n+ { status: 404 },\\n+ );\\n+ }\\n+\\n+ // Get active jobs for this request\\n+ const jobs = await requestProgressQueue.getJobs([\\\"waiting\\\"]);\\n+ const job = jobs.find((job) => job.data.requestId === requestId);\\n+\\n+ if (job) {\\n+ await job.remove();\\n+ }\\n+\\n+ // Update request status\\n+ await RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ { status: \\\"cancelled\\\" },\\n+ );\\n+\\n+ return NextResponse.json({ success: true });\\n+ } catch (error) {\\n+ console.error(\\\"Cancel request error:\\\", error);\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\ndiff --git a/app/api/chats/_lib.js b/app/api/chats/_lib.js\\nindex 0a8e842..7e52d78 100644\\n--- a/app/api/chats/_lib.js\\n+++ b/app/api/chats/_lib.js\\n@@ -20,6 +20,24 @@ export async function getRecentChatsOfCurrentUser() {\\n { _id: 1, title: 1, titleSetByUser: 1 },\\n );\\n \\n+ // For chats without a custom title, fetch the first message separately\\n+ // This approach avoids truncating the messages array in the main cache\\n+ for (const chat of recentChatsUnordered) {\\n+ if (!chat.title || chat.title === \\\"New Chat\\\" || chat.title === \\\"\\\") {\\n+ const chatWithFirstMessage = await Chat.findOne(\\n+ { _id: chat._id },\\n+ { messages: { $slice: 1 } },\\n+ );\\n+ if (\\n+ chatWithFirstMessage &&\\n+ chatWithFirstMessage.messages &&\\n+ chatWithFirstMessage.messages.length > 0\\n+ ) {\\n+ chat._doc.firstMessage = chatWithFirstMessage.messages[0];\\n+ }\\n+ }\\n+ }\\n+\\n const recentChatsMap = recentChatsUnordered.reduce((acc, chat) => {\\n acc[chat._id] = chat;\\n return acc;\\ndiff --git a/app/api/models/request-progress.mjs b/app/api/models/request-progress.mjs\\nnew file mode 100644\\nindex 0000000..820b682\\n--- /dev/null\\n+++ b/app/api/models/request-progress.mjs\\n@@ -0,0 +1,60 @@\\n+import mongoose from \\\"mongoose\\\";\\n+\\n+const requestProgressSchema = new mongoose.Schema(\\n+ {\\n+ requestId: {\\n+ type: String,\\n+ required: true,\\n+ unique: true,\\n+ },\\n+ owner: {\\n+ type: mongoose.Schema.Types.ObjectId,\\n+ ref: \\\"User\\\",\\n+ required: true,\\n+ },\\n+ progress: {\\n+ type: Number,\\n+ required: true,\\n+ default: 0,\\n+ },\\n+ data: mongoose.Schema.Types.Mixed,\\n+ statusText: String,\\n+ status: {\\n+ type: String,\\n+ enum: [\\n+ \\\"pending\\\",\\n+ \\\"in_progress\\\",\\n+ \\\"completed\\\",\\n+ \\\"failed\\\",\\n+ \\\"cancelled\\\",\\n+ ],\\n+ default: \\\"pending\\\",\\n+ },\\n+ error: String,\\n+ type: {\\n+ type: String,\\n+ required: true,\\n+ },\\n+ metadata: {\\n+ type: mongoose.Schema.Types.Mixed,\\n+ default: null,\\n+ },\\n+ dismissed: {\\n+ type: Boolean,\\n+ default: false,\\n+ },\\n+ },\\n+ {\\n+ timestamps: true,\\n+ },\\n+);\\n+\\n+requestProgressSchema.index({ requestId: 1 });\\n+requestProgressSchema.index({ createdAt: -1 });\\n+requestProgressSchema.index({ owner: 1 });\\n+\\n+const RequestProgress =\\n+ mongoose.models.RequestProgress ||\\n+ mongoose.model(\\\"RequestProgress\\\", requestProgressSchema);\\n+\\n+export default RequestProgress;\\ndiff --git a/app/api/models/user-state.js b/app/api/models/user-state.mjs\\nsimilarity index 100%\\nrename from app/api/models/user-state.js\\nrename to app/api/models/user-state.mjs\\ndiff --git a/app/api/models/user.mjs b/app/api/models/user.mjs\\nindex 7bea9fc..a72968f 100644\\n--- a/app/api/models/user.mjs\\n+++ b/app/api/models/user.mjs\\n@@ -42,6 +42,11 @@ const userSchema = new mongoose.Schema(\\n required: true,\\n default: \\\"OpenAI\\\",\\n },\\n+ streamingEnabled: {\\n+ type: Boolean,\\n+ required: true,\\n+ default: false,\\n+ },\\n uploadedDocs: {\\n type: [uploadedDocsSchema],\\n required: false,\\ndiff --git a/app/api/options/route.js b/app/api/options/route.js\\nindex 1bda936..84173af 100644\\n--- a/app/api/options/route.js\\n+++ b/app/api/options/route.js\\n@@ -5,7 +5,14 @@ export async function POST(req) {\\n try {\\n const body = await req.json();\\n \\n- const { userId, contextId, aiMemorySelfModify, aiName, aiStyle } = body;\\n+ const {\\n+ userId,\\n+ contextId,\\n+ aiMemorySelfModify,\\n+ aiName,\\n+ aiStyle,\\n+ streamingEnabled,\\n+ } = body;\\n \\n if (!mongoose.connection.readyState) {\\n throw new Error(\\\"Database is not connected\\\");\\n@@ -26,6 +33,9 @@ export async function POST(req) {\\n if (aiStyle !== undefined) {\\n user.aiStyle = aiStyle;\\n }\\n+ if (streamingEnabled !== undefined) {\\n+ user.streamingEnabled = streamingEnabled;\\n+ }\\n await user.save();\\n return Response.json({ status: \\\"success\\\" });\\n } else {\\ndiff --git a/app/api/request-progress/route.js b/app/api/request-progress/route.js\\nnew file mode 100644\\nindex 0000000..506f11d\\n--- /dev/null\\n+++ b/app/api/request-progress/route.js\\n@@ -0,0 +1,64 @@\\n+import RequestProgress from \\\"../models/request-progress\\\";\\n+import { NextResponse } from \\\"next/server\\\";\\n+import { getCurrentUser } from \\\"../utils/auth\\\";\\n+\\n+export async function GET(request) {\\n+ try {\\n+ const user = await getCurrentUser();\\n+ const { searchParams } = new URL(request.url);\\n+ const showDismissed = searchParams.get(\\\"showDismissed\\\") === \\\"true\\\";\\n+ const page = parseInt(searchParams.get(\\\"page\\\")) || 1;\\n+ const limit = parseInt(searchParams.get(\\\"limit\\\")) || 10;\\n+\\n+ const query = {\\n+ owner: user._id,\\n+ };\\n+\\n+ if (!showDismissed) {\\n+ query.dismissed = { $ne: true };\\n+ const fortyEightHoursAgo = new Date(\\n+ Date.now() - 48 * 60 * 60 * 1000,\\n+ );\\n+ query.createdAt = { $gte: fortyEightHoursAgo };\\n+ }\\n+\\n+ const requests = await RequestProgress.find(query)\\n+ .sort({ createdAt: -1 })\\n+ .skip((page - 1) * limit)\\n+ .limit(limit);\\n+\\n+ const total = await RequestProgress.countDocuments(query);\\n+\\n+ return NextResponse.json({\\n+ requests,\\n+ hasMore: total > page * limit,\\n+ });\\n+ } catch (error) {\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\n+\\n+export async function PATCH(request) {\\n+ try {\\n+ const user = await getCurrentUser();\\n+ const { requestId } = await request.json();\\n+ await RequestProgress.findOneAndUpdate(\\n+ { requestId, owner: user._id },\\n+ { dismissed: true },\\n+ );\\n+ return NextResponse.json({ success: true });\\n+ } catch (error) {\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\n+\\n+export async function DELETE(request) {\\n+ try {\\n+ const user = await getCurrentUser();\\n+ const { requestId } = await request.json();\\n+ await RequestProgress.findOneAndDelete({ requestId, owner: user._id });\\n+ return NextResponse.json({ success: true });\\n+ } catch (error) {\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\ndiff --git a/app/api/users/me/state/route.js b/app/api/users/me/state/route.js\\nindex aa35018..7310e59 100644\\n--- a/app/api/users/me/state/route.js\\n+++ b/app/api/users/me/state/route.js\\n@@ -1,4 +1,4 @@\\n-import UserState from \\\"../../../models/user-state\\\";\\n+import UserState from \\\"../../../models/user-state.mjs\\\";\\n import { getCurrentUser } from \\\"../../../utils/auth\\\";\\n \\n function transformUserState(userState) {\\ndiff --git a/app/notifications/NotificationsPage.js b/app/notifications/NotificationsPage.js\\nnew file mode 100644\\nindex 0000000..ce16b64\\n--- /dev/null\\n+++ b/app/notifications/NotificationsPage.js\\n@@ -0,0 +1,220 @@\\n+\\\"use client\\\";\\n+import { TrashIcon, XIcon } from \\\"lucide-react\\\";\\n+import { useEffect, useState, useCallback } from \\\"react\\\";\\n+import { useTranslation } from \\\"react-i18next\\\";\\n+import { useInView } from \\\"react-intersection-observer\\\";\\n+import TimeAgo from \\\"react-time-ago\\\";\\n+import stringcase from \\\"stringcase\\\";\\n+import {\\n+ useDeleteNotification,\\n+ useInfiniteNotifications,\\n+ useCancelRequest,\\n+} from \\\"../../app/queries/notifications\\\";\\n+import {\\n+ NotificationDisplayType,\\n+ StatusIndicator,\\n+ getStatusColorClass,\\n+} from \\\"../../src/components/notifications/NotificationButton\\\";\\n+import {\\n+ AlertDialog,\\n+ AlertDialogAction,\\n+ AlertDialogCancel,\\n+ AlertDialogContent,\\n+ AlertDialogDescription,\\n+ AlertDialogFooter,\\n+ AlertDialogHeader,\\n+ AlertDialogTitle,\\n+} from \\\"@/components/ui/alert-dialog\\\";\\n+\\n+export default function NotificationsPage() {\\n+ const { t } = useTranslation();\\n+ const { ref, inView } = useInView();\\n+\\n+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =\\n+ useInfiniteNotifications();\\n+\\n+ const deleteNotification = useDeleteNotification();\\n+ const [cancelRequestId, setCancelRequestId] = useState(null);\\n+ const cancelRequest = useCancelRequest();\\n+\\n+ useEffect(() => {\\n+ if (inView && hasNextPage) {\\n+ fetchNextPage();\\n+ }\\n+ }, [inView, hasNextPage, fetchNextPage]);\\n+\\n+ const handleDelete = (requestId) => {\\n+ if (\\n+ window.confirm(\\n+ t(\\\"Are you sure you want to delete this notification?\\\"),\\n+ )\\n+ ) {\\n+ deleteNotification.mutate(requestId);\\n+ }\\n+ };\\n+\\n+ const handleCancelRequest = (requestId) => {\\n+ setCancelRequestId(requestId);\\n+ };\\n+\\n+ const confirmCancel = useCallback(async () => {\\n+ if (cancelRequestId) {\\n+ await cancelRequest.mutate(cancelRequestId);\\n+ setCancelRequestId(null);\\n+ }\\n+ }, [cancelRequestId, cancelRequest]);\\n+\\n+ const notifications = data?.pages.flatMap((page) => page.requests) ?? [];\\n+\\n+ return (\\n+ <div className=\\\"p-2\\\">\\n+ <h1 className=\\\"text-2xl font-bold mb-6\\\">\\n+ {t(\\\"All notifications\\\")}\\n+ </h1>\\n+ <div className=\\\"space-y-4\\\">\\n+ {status === \\\"pending\\\" ? (\\n+ <div className=\\\"flex justify-center\\\">\\n+ <div className=\\\"animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900\\\" />\\n+ </div>\\n+ ) : notifications.length === 0 ? (\\n+ <p className=\\\"text-sm text-gray-500\\\">\\n+ {t(\\\"No notifications\\\")}\\n+ </p>\\n+ ) : (\\n+ <>\\n+ {notifications.map((notification) => (\\n+ <div\\n+ key={notification.requestId}\\n+ className=\\\"space-y-2 bg-gray-100 p-3 rounded-md\\\"\\n+ >\\n+ <div className=\\\"flex gap-3\\\">\\n+ <div className=\\\"ps-1 pt-1\\\">\\n+ <StatusIndicator\\n+ status={notification.status}\\n+ />\\n+ </div>\\n+ <div className=\\\"flex flex-col grow overflow-hidden\\\">\\n+ <div className=\\\"flex justify-between items-start\\\">\\n+ <span className=\\\"font-semibold\\\">\\n+ {t(\\n+ NotificationDisplayType[\\n+ notification.type\\n+ ],\\n+ )}\\n+ </span>\\n+ <div className=\\\"flex gap-2\\\">\\n+ {notification.status ===\\n+ \\\"in_progress\\\" && (\\n+ <button\\n+ onClick={() =>\\n+ handleCancelRequest(\\n+ notification.requestId,\\n+ )\\n+ }\\n+ className=\\\"p-1 rounded flex items-center gap-1 text-sm text-gray-500 hover:text-red-500\\\"\\n+ title={t(\\\"Cancel\\\")}\\n+ >\\n+ <XIcon className=\\\"h-4 w-4\\\" />\\n+ </button>\\n+ )}\\n+ {(notification.status ===\\n+ \\\"completed\\\" ||\\n+ notification.status ===\\n+ \\\"failed\\\" ||\\n+ notification.status ===\\n+ \\\"cancelled\\\") && (\\n+ <button\\n+ onClick={() =>\\n+ handleDelete(\\n+ notification.requestId,\\n+ )\\n+ }\\n+ className=\\\"p-1 rounded flex items-center gap-1 text-sm text-gray-500 hover:text-red-500\\\"\\n+ title={t(\\\"Delete\\\")}\\n+ >\\n+ <TrashIcon className=\\\"h-4 w-4\\\" />\\n+ </button>\\n+ )}\\n+ </div>\\n+ </div>\\n+ {notification.createdAt && (\\n+ <span className=\\\"text-xs text-gray-500\\\">\\n+ {t(\\\"Created \\\")}{\\\" \\\"}\\n+ <TimeAgo\\n+ date={\\n+ notification.createdAt\\n+ }\\n+ />\\n+ </span>\\n+ )}\\n+ <span\\n+ className={`text-sm ${notification.status === \\\"failed\\\" ? \\\"text-red-500\\\" : \\\"text-gray-500\\\"}`}\\n+ >\\n+ {notification.statusText ||\\n+ (notification.status ===\\n+ \\\"failed\\\"\\n+ ? t(\\\"Request failed\\\")\\n+ : \\\"\\\")}\\n+ </span>\\n+ <span\\n+ className={`text-sm font-semibold ${getStatusColorClass(notification.status)}`}\\n+ >\\n+ {t(\\n+ stringcase.sentencecase(\\n+ notification.status,\\n+ ),\\n+ )}\\n+ </span>\\n+\\n+ {notification.status ===\\n+ \\\"in_progress\\\" && (\\n+ <div className=\\\"my-2 h-2 w-full bg-gray-200 rounded-full\\\">\\n+ <div\\n+ className=\\\"h-full bg-sky-600 rounded-full transition-all duration-300\\\"\\n+ style={{\\n+ width: `${notification.progress * 100}%`,\\n+ }}\\n+ />\\n+ </div>\\n+ )}\\n+ </div>\\n+ </div>\\n+ </div>\\n+ ))}\\n+\\n+ <div ref={ref} className=\\\"py-4\\\">\\n+ {isFetchingNextPage && (\\n+ <div className=\\\"flex justify-center\\\">\\n+ <div className=\\\"animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900\\\" />\\n+ </div>\\n+ )}\\n+ </div>\\n+ </>\\n+ )}\\n+ </div>\\n+ <AlertDialog\\n+ open={!!cancelRequestId}\\n+ onOpenChange={() => setCancelRequestId(null)}\\n+ >\\n+ <AlertDialogContent>\\n+ <AlertDialogHeader>\\n+ <AlertDialogTitle>\\n+ {t(\\\"Confirm Cancellation\\\")}\\n+ </AlertDialogTitle>\\n+ <AlertDialogDescription>\\n+ {t(\\n+ \\\"Are you sure you want to cancel this request? This action cannot be undone.\\\",\\n+ )}\\n+ </AlertDialogDescription>\\n+ </AlertDialogHeader>\\n+ <AlertDialogFooter>\\n+ <AlertDialogCancel>{t(\\\"No\\\")}</AlertDialogCancel>\\n+ <AlertDialogAction onClick={confirmCancel}>\\n+ {t(\\\"Yes, Cancel Request\\\")}\\n+ </AlertDialogAction>\\n+ </AlertDialogFooter>\\n+ </AlertDialogContent>\\n+ </AlertDialog>\\n+ </div>\\n+ );\\n+}\\ndiff --git a/app/notifications/page.js b/app/notifications/page.js\\nnew file mode 100644\\nindex 0000000..10354aa\\n--- /dev/null\\n+++ b/app/notifications/page.js\\n@@ -0,0 +1,5 @@\\n+import NotificationsPage from \\\"./NotificationsPage\\\";\\n+\\n+export default function Page() {\\n+ return <NotificationsPage />;\\n+}\\ndiff --git a/app/providers.js b/app/providers.js\\nindex 86f9bc3..24a2a6b 100644\\n--- a/app/providers.js\\n+++ b/app/providers.js\\n@@ -1,6 +1,7 @@\\n // In Next.js, this file would be called: app/providers.jsx\\n \\\"use client\\\";\\n import { QueryClient, QueryClientProvider } from \\\"@tanstack/react-query\\\";\\n+import { NotificationProvider } from \\\"../src/contexts/NotificationContext\\\";\\n \\n function makeQueryClient() {\\n return new QueryClient({\\n@@ -39,7 +40,7 @@ export default function Providers({ children }) {\\n \\n return (\\n <QueryClientProvider client={queryClient}>\\n- {children}\\n+ <NotificationProvider>{children}</NotificationProvider>\\n </QueryClientProvider>\\n );\\n }\\ndiff --git a/app/queries/chats.js b/app/queries/chats.js\\nindex 186fb53..718c26a 100644\\n--- a/app/queries/chats.js\\n+++ b/app/queries/chats.js\\n@@ -38,10 +38,21 @@ export function useGetActiveChats() {\\n activeChats.forEach((chat) => {\\n const existingChat =\\n queryClient.getQueryData([\\\"chat\\\", chat._id]) || {};\\n- queryClient.setQueryData([\\\"chat\\\", chat._id], {\\n- ...existingChat,\\n- ...chat,\\n- });\\n+\\n+ // If chat has a firstMessage property but the existing chat has a full messages array,\\n+ // keep the existing messages and don't overwrite with the truncated version\\n+ const updatedChat = { ...existingChat, ...chat };\\n+\\n+ // Only preserve existing messages if they exist and are not empty\\n+ if (\\n+ chat.firstMessage &&\\n+ existingChat.messages &&\\n+ existingChat.messages.length > 0\\n+ ) {\\n+ updatedChat.messages = existingChat.messages;\\n+ }\\n+\\n+ queryClient.setQueryData([\\\"chat\\\", chat._id], updatedChat);\\n });\\n return activeChats;\\n },\\n@@ -53,10 +64,12 @@ export function useGetActiveChats() {\\n }\\n \\n function temporaryNewChat({ messages, title }) {\\n+ const tempId = `temp_${Date.now()}_${crypto.randomUUID()}`;\\n return {\\n- _id: null,\\n+ _id: tempId,\\n messages: messages || [],\\n title: title || \\\"\\\",\\n+ isTemporary: true,\\n };\\n }\\n \\n@@ -71,52 +84,170 @@ export function useAddChat() {\\n });\\n return response.data;\\n },\\n- onMutate: async ({ messages, title }) => {\\n+ // Using the standard Tanstack Query pattern for optimistic updates\\n+ onMutate: async (newChatData) => {\\n+ // Cancel related queries to prevent race conditions\\n+ await queryClient.cancelQueries({\\n+ queryKey: [\\\"activeChats\\\", \\\"userChatInfo\\\", \\\"chats\\\"],\\n+ });\\n+\\n+ // Snapshot the current state\\n const previousActiveChats =\\n queryClient.getQueryData([\\\"activeChats\\\"]) || [];\\n const previousUserChatInfo =\\n queryClient.getQueryData([\\\"userChatInfo\\\"]) || {};\\n- const newChat = temporaryNewChat({ messages, title });\\n \\n+ // Create an optimistic chat entry\\n+ const optimisticChat = temporaryNewChat(newChatData);\\n+\\n+ // Update all relevant query data optimistically\\n+ queryClient.setQueryData(\\n+ [\\\"chat\\\", optimisticChat._id],\\n+ optimisticChat,\\n+ );\\n queryClient.setQueryData(\\n [\\\"activeChats\\\"],\\n- [newChat, ...previousActiveChats],\\n+ [optimisticChat, ...previousActiveChats],\\n );\\n queryClient.setQueryData([\\\"userChatInfo\\\"], {\\n ...previousUserChatInfo,\\n- activeChatId: newChat._id,\\n+ activeChatId: optimisticChat._id,\\n+ recentChatIds: previousUserChatInfo.recentChatIds\\n+ ? [\\n+ optimisticChat._id,\\n+ ...previousUserChatInfo.recentChatIds\\n+ .filter((id) => id !== optimisticChat._id)\\n+ .slice(0, 2),\\n+ ]\\n+ : [optimisticChat._id],\\n });\\n \\n- return { previousActiveChats, previousUserChatInfo };\\n- },\\n- onSuccess: (newChat) => {\\n- queryClient.setQueryData([\\\"chat\\\", newChat._id], newChat);\\n- queryClient.setQueryData([\\\"activeChats\\\"], (oldChats = []) => [\\n- newChat,\\n- ...oldChats.filter(\\n- (chat) => chat._id !== null && chat._id !== newChat._id,\\n- ),\\n- ]);\\n- queryClient.setQueryData([\\\"userChatInfo\\\"], (oldInfo) => ({\\n- ...oldInfo,\\n- activeChatId: newChat._id,\\n- }));\\n- queryClient.invalidateQueries({ queryKey: [\\\"userChatInfo\\\"] });\\n- queryClient.invalidateQueries({ queryKey: [\\\"activeChats\\\"] });\\n- queryClient.invalidateQueries({ queryKey: [\\\"chats\\\"] });\\n+ // Return context for potential rollback\\n+ return {\\n+ previousActiveChats,\\n+ previousUserChatInfo,\\n+ optimisticChatId: optimisticChat._id,\\n+ };\\n },\\n- onError: (err, variables, context) => {\\n- if (context?.previousActiveChats) {\\n+ onError: (err, newChat, context) => {\\n+ // On error, roll back to the previous state\\n+ if (context) {\\n queryClient.setQueryData(\\n [\\\"activeChats\\\"],\\n context.previousActiveChats,\\n );\\n- }\\n- if (context?.previousUserChatInfo) {\\n queryClient.setQueryData(\\n [\\\"userChatInfo\\\"],\\n context.previousUserChatInfo,\\n );\\n+ queryClient.removeQueries({\\n+ queryKey: [\\\"chat\\\", context.optimisticChatId],\\n+ });\\n+ }\\n+ },\\n+ onSuccess: (serverChat, variables, context) => {\\n+ // Remove the optimistic entry\\n+ if (context?.optimisticChatId) {\\n+ queryClient.removeQueries({\\n+ queryKey: [\\\"chat\\\", context.optimisticChatId],\\n+ });\\n+ }\\n+\\n+ // Add the confirmed server data\\n+ queryClient.setQueryData([\\\"chat\\\", serverChat._id], serverChat);\\n+\\n+ // Update active chats by replacing the optimistic version\\n+ queryClient.setQueryData([\\\"activeChats\\\"], (oldData = []) => {\\n+ return [\\n+ serverChat,\\n+ ...oldData.filter(\\n+ (chat) =>\\n+ chat._id !== context?.optimisticChatId &&\\n+ chat._id !== serverChat._id,\\n+ ),\\n+ ];\\n+ });\\n+\\n+ // Update the userChatInfo with the actual chat ID\\n+ queryClient.setQueryData([\\\"userChatInfo\\\"], (oldData = {}) => {\\n+ return {\\n+ ...oldData,\\n+ activeChatId: serverChat._id,\\n+ recentChatIds: oldData.recentChatIds\\n+ ? [\\n+ serverChat._id,\\n+ ...oldData.recentChatIds.filter(\\n+ (id) =>\\n+ id !== context?.optimisticChatId &&\\n+ id !== serverChat._id,\\n+ ),\\n+ ]\\n+ : [serverChat._id],\\n+ };\\n+ });\\n+ },\\n+ onSettled: () => {\\n+ // Always refresh the data to ensure consistency\\n+ queryClient.invalidateQueries({ queryKey: [\\\"chats\\\"] });\\n+ queryClient.invalidateQueries({ queryKey: [\\\"activeChats\\\"] });\\n+ queryClient.invalidateQueries({ queryKey: [\\\"userChatInfo\\\"] });\\n+ },\\n+ });\\n+}\\n+\\n+// The useAddMessage function will now automatically leverage the optimistic behavior\\n+// of useAddChat if no chatId is provided\\n+export function useAddMessage() {\\n+ const queryClient = useQueryClient();\\n+ const addChatMutation = useAddChat();\\n+\\n+ return useMutation({\\n+ mutationFn: async ({ message, chatId }) => {\\n+ let chatData;\\n+ if (!chatId) {\\n+ // No changes needed here - the optimistic updates are handled in useAddChat\\n+ const newChat = await addChatMutation.mutateAsync({\\n+ messages: [message],\\n+ });\\n+ chatId = String(newChat?._id);\\n+ chatData = newChat;\\n+ } else {\\n+ const chatResponse = await axios.post(\\n+ `/api/chats/${String(chatId)}`,\\n+ { message },\\n+ );\\n+ chatData = chatResponse.data;\\n+ queryClient.setQueryData([\\\"chat\\\", String(chatId)], chatData);\\n+ }\\n+ return chatData;\\n+ },\\n+ onMutate: ({ message, chatId }) => {\\n+ if (!chatId || !message) return;\\n+ const existingChat = queryClient.getQueryData([\\n+ \\\"chat\\\",\\n+ String(chatId),\\n+ ]);\\n+ const expectedChatData = {\\n+ ...existingChat,\\n+ messages: [...(existingChat?.messages || []), message],\\n+ };\\n+ queryClient.setQueryData(\\n+ [\\\"chat\\\", String(chatId)],\\n+ expectedChatData,\\n+ );\\n+ },\\n+ onSuccess: (updatedChat) => {\\n+ queryClient.setQueryData(\\n+ [\\\"chat\\\", String(updatedChat?._id)],\\n+ updatedChat,\\n+ );\\n+ },\\n+ onError: (err, variables, context) => {\\n+ if (context?.previousChat) {\\n+ queryClient.setQueryData(\\n+ [\\\"chat\\\", String(context.previous