diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c1dbd8b34c8312df3c3d9aa6845b714f9bf11ad7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# Dependencies +**/node_modules +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock +*.bak + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Database +*.db +*.sqlite +*.sqlite3 diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..72842592fd0b2d2e7e186e4beeeacada46dabed7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Generated files +*.tsbuildinfo +coverage/ + +# Package files +package-lock.json +pnpm-lock.yaml + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# Environment files +.env* + +# IDE files +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..67c0bc83c28a85bbcd4cb081fa91d30b4a79fcfd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "proseWrap": "preserve" +} diff --git a/DEVELOPMENT_GUIDE.md b/DEVELOPMENT_GUIDE.md new file mode 100644 index 0000000000000000000000000000000000000000..04373fa0c41835aa97bf520aafce780799ad5387 --- /dev/null +++ b/DEVELOPMENT_GUIDE.md @@ -0,0 +1,255 @@ +# HackingFactory v2 - Development Guide + +## نظرة عامة + +**HackingFactory** هو نموذج متقدم لـ "أنظمة الذكاء الاصطناعي ذاتية التحسين" (Self-Refining AI Systems). يجمع بين قوة نماذج التوليد (Qwen) والتقييم (DeepSeek) في حلقة تكرارية مستمرة لإنتاج أكواد عالية الجودة. + +--- + +## المكونات الرئيسية + +### 1. **بيئة الفحص المعزولة (Sandbox)** +**الملف:** `server/sandbox.ts` + +يوفر بيئة معزولة آمنة لتشغيل الأكواد المولدة واختبارها ديناميكياً: +- تشغيل الأكواد بحد زمني (5 ثوان) +- قياس الأداء ووقت التنفيذ +- التقاط المخرجات والأخطاء + +```typescript +const result = await runCodeInSandbox(code, "python"); +// { success: boolean, output: string, error?: string, executionTime: number } +``` + +### 2. **محرك التكرار الذاتي (Self-Refining Loop)** +**الملف:** `server/factory.ts` + +يدير العملية الكاملة للتوليد والتقييم والتحسين: +1. البحث في الذاكرة عن حلول مشابهة سابقة +2. توليد كود جديد (Qwen) +3. فحص ديناميكي (Sandbox) +4. تقييم شامل (DeepSeek) +5. حفظ النتائج والتكرار حتى الوصول للدرجة المستهدفة + +```typescript +await runSelfRefiningLoop(projectId, prompt, targetScore, maxIterations); +``` + +### 3. **قاعدة البيانات المتجهة (Vector Memory)** +**الملف:** `server/memory.ts` + +تخزين واسترجاع الحلول الناجحة السابقة باستخدام ChromaDB: +- البحث الدلالي عن حلول مشابهة +- تقليل وقت التوليد للمشاكل المتكررة +- تحسين جودة الحلول الجديدة + +```typescript +const memory = ProjectMemory.getInstance(); +await memory.addSuccessfulCode(projectId, code, prompt, score); +const similar = await memory.findSimilarSolutions(query); +``` + +### 4. **نظام التقييم المتعدد (Multi-Criteria Scoring)** +**الملف:** `server/routers/chat.ts` - دالة `evaluateCode` + +تقييم الأكواد بناءً على أربعة معايير (0-25 نقطة لكل معيار): +1. **التخفي (Stealth):** إخفاء الأثر، تجنب الكشف +2. **الاستقرار (Stability):** معالجة الأخطاء، إدارة الموارد +3. **الكفاءة (Efficiency):** الأداء، سرعة التنفيذ +4. **الموثوقية (Reliability):** معدل النجاح، التوافقية + +### 5. **محرك التشفير والتخفي (Obfuscation Engine)** +**الملف:** `server/obfuscation.ts` + +يوفر عدة مستويات من التشفير: +- **Low:** إعادة تسمية المتغيرات +- **Medium:** تشفير النصوص + إضافة كود وهمي +- **High:** مستويات متعددة من التشفير + +```typescript +const obfuscated = ObfuscationEngine.fullObfuscate(code, "high"); +``` + +### 6. **نظام الإشعارات (Notifications)** +**الملف:** `server/notifications.ts` + +إخطار المستخدمين بتقدم المشاريع: +- إكمال التكرارات +- إكمال المشاريع +- الأخطاء والتحذيرات + +```typescript +const notifier = NotificationManager.getInstance(); +await notifier.notifyProjectComplete(userId, projectName, score, projectId); +``` + +### 7. **لوحة التحكم المرئية (Visual Dashboard)** +**الملف:** `client/src/pages/Dashboard.tsx` + +واجهة رسومية تعرض: +- إحصائيات المشاريع +- رسم بياني لتقدم التكرارات (Iteration Progress Chart) +- قائمة المشاريع النشطة +- معلومات مفصلة عن كل مشروع + +### 8. **محرر الأكواد المباشر (Live Code Editor)** +**الملف:** `client/src/components/CodeEditor.tsx` + +يسمح للمستخدمين بـ: +- تحرير الأكواد يدوياً +- نسخ الأكواد +- تشغيل الأكواد مباشرة + +### 9. **مكتبة القوالب (Payload Library)** +**الملف:** `client/src/components/PayloadLibrary.tsx` + +مكتبة منظمة للحلول الناجحة: +- البحث والتصفية حسب الوسوم +- عرض درجات الجودة +- تطبيق التشفير على القوالب + +--- + +## سير العمل (Workflow) + +### 1. إنشاء مشروع جديد +```typescript +const project = await trpc.projects.create.mutate({ + name: "My Project", + mode: "loop", + contentType: "code", + originalPrompt: "Generate a reverse shell...", +}); +``` + +### 2. تشغيل المصنع +```typescript +await trpc.projects.runFactory.mutate({ + projectId: project.projectId, + prompt: project.originalPrompt, + targetScore: 90, +}); +``` + +### 3. مراقبة التقدم +```typescript +const iterations = await trpc.projects.getIterations.query({ + projectId: project.projectId, +}); +``` + +### 4. تطبيق التشفير (Ghost Mode) +```typescript +const obfuscated = await trpc.projects.obfuscateCode.mutate({ + code: finalCode, + level: "high", +}); +``` + +--- + +## البنية الديناميكية (Dynamic Analysis) + +عند تقييم الكود، يتم تنفيذه فعلياً في بيئة معزولة: + +``` +Input Code + ↓ +[Sandbox Execution] → Output, Errors, Execution Time + ↓ +[LLM Analysis] → Scoring (Stealth, Stability, Efficiency, Reliability) + ↓ +[Iteration Storage] → Save to Database + ↓ +[Score Check] → If score < target, repeat generation +``` + +--- + +## التكامل مع الواجهة الأمامية + +### مسارات tRPC المتاحة + +**Projects Router:** +- `create` - إنشاء مشروع جديد +- `list` - قائمة المشاريع +- `getById` - الحصول على تفاصيل مشروع +- `updateStatus` - تحديث حالة المشروع +- `addIteration` - إضافة تكرار جديد +- `getIterations` - الحصول على التكرارات +- `runFactory` - تشغيل المصنع +- `obfuscateCode` - تطبيق التشفير + +**Chat Router:** +- `sendMessage` - إرسال رسالة للذكاء الاصطناعي +- `generateCode` - توليد كود +- `evaluateCode` - تقييم كود + +--- + +## متطلبات التثبيت + +```bash +# تثبيت المكتبات +npm install + +# تثبيت ChromaDB (Python) +sudo pip3 install chromadb + +# تشغيل الخادم +npm run dev +``` + +--- + +## ملاحظات تطويرية + +### الأداء +- استخدام الخيط الخلفي (Background) لتشغيل المصنع لتجنب timeout +- تخزين النتائج في الذاكرة المتجهة لتسريع البحث + +### الأمان +- جميع الأكواد تُشغل في بيئة معزولة +- لا توجد وصول مباشر للنظام الرئيسي +- التحقق من صلاحيات المستخدم على جميع المسارات + +### التوسع المستقبلي +- دعم نماذج استدلال متقدمة (o1, DeepSeek-R1) +- تكامل Docker للفحص الديناميكي المتقدم +- نظام التنبيهات عبر WebSocket +- تصدير النتائج بصيغ متعددة + +--- + +## أمثلة الاستخدام + +### مثال 1: توليد وتقييم كود بسيط +```typescript +const result = await trpc.chat.generateCode.mutate({ + projectId: 1, + prompt: "Write a Python script that prints hello world", +}); +``` + +### مثال 2: تشغيل حلقة التكرار الكاملة +```typescript +await trpc.projects.runFactory.mutate({ + projectId: 1, + prompt: "Generate a secure password generator", + targetScore: 95, +}); +``` + +### مثال 3: تطبيق التشفير على الكود +```typescript +const ghostCode = await trpc.projects.obfuscateCode.mutate({ + code: "print('Hello')", + level: "high", +}); +``` + +--- + +## الدعم والمساهمة + +للمزيد من المعلومات أو الإبلاغ عن مشاكل، يرجى فتح issue في المستودع. diff --git a/README_ENHANCEMENTS.md b/README_ENHANCEMENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..13f8100ec0c1cbcb53f9f8a135dfd1f689d67332 --- /dev/null +++ b/README_ENHANCEMENTS.md @@ -0,0 +1,253 @@ +# HackingFactory v2 - التطويرات الجديدة + +## ملخص التحديثات + +تم تطوير **HackingFactory** ليصبح نظاماً متقدماً لـ "الذكاء الاصطناعي ذاتي التحسين" مع المميزات التالية: + +--- + +## 🎯 المميزات الرئيسية + +### 1. **بيئة الفحص الديناميكية (Dynamic Sandbox)** +- تشغيل الأكواد المولدة في بيئة معزولة آمنة +- قياس الأداء والموارد المستخدمة +- اكتشاف الأخطاء والمشاكل قبل النشر +- **الملف:** `server/sandbox.ts` + +### 2. **نظام التقييم المتعدد المعايير (Multi-Criteria Scoring)** +تقييم شامل للأكواد بناءً على: +- **التخفي (Stealth):** مدى إخفاء الأثر والكشف +- **الاستقرار (Stability):** معالجة الأخطاء والموارد +- **الكفاءة (Efficiency):** الأداء والسرعة +- **الموثوقية (Reliability):** معدل النجاح والتوافقية + +### 3. **حلقة التكرار الذاتي (Self-Refining Loop)** +- توليد تلقائي للأكواد (Qwen) +- تقييم ذكي (DeepSeek) +- تحسين مستمر حتى الوصول للدرجة المستهدفة +- **الملف:** `server/factory.ts` + +### 4. **الذاكرة المتجهة (Vector Memory)** +- تخزين الحلول الناجحة السابقة +- البحث الدلالي عن حلول مشابهة +- تسريع التوليد للمشاكل المتكررة +- **الملف:** `server/memory.ts` +- **التقنية:** ChromaDB + +### 5. **محرك التشفير والتخفي (Obfuscation Engine)** +ثلاث مستويات من التشفير: +- **Low:** إعادة تسمية المتغيرات +- **Medium:** تشفير النصوص + كود وهمي +- **High:** تشفير متعدد المستويات +- **الملف:** `server/obfuscation.ts` + +### 6. **لوحة التحكم المرئية (Visual Dashboard)** +- عرض إحصائيات المشاريع +- رسم بياني لتقدم التكرارات +- معلومات مفصلة عن كل مشروع +- **الملف:** `client/src/pages/Dashboard.tsx` + +### 7. **محرر الأكواد المباشر (Live Code Editor)** +- تحرير الأكواد يدوياً +- نسخ وتحميل الأكواد +- تشغيل الأكواد مباشرة +- **الملف:** `client/src/components/CodeEditor.tsx` + +### 8. **مكتبة القوالب (Payload Library)** +- مكتبة منظمة للحلول الناجحة +- البحث والتصفية حسب الوسوم +- تطبيق التشفير على القوالب +- **الملف:** `client/src/components/PayloadLibrary.tsx` + +### 9. **نظام الإشعارات (Notifications)** +- إخطار المستخدمين بتقدم المشاريع +- تنبيهات الإكمال والأخطاء +- **الملف:** `server/notifications.ts` + +--- + +## 📊 معمارية النظام + +``` +┌─────────────────────────────────────────────────────────┐ +│ User Interface │ +│ (Dashboard | Chat | Code Editor | Payload Library) │ +└──────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────────┐ +│ tRPC API Layer │ +│ (Projects Router | Chat Router | Payload Router) │ +└──────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────────┐ +│ Self-Refining Loop Engine │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Generation │→ │ Sandbox Test │→ │ Evaluation │ │ +│ │ (Qwen) │ │ (Dynamic) │ │ (DeepSeek) │ │ +│ └─────────────┘ └──────────────┘ └──────────────┘ │ +└──────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────────┐ +│ Data & Memory Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ MySQL DB │ │ ChromaDB │ │ Obfuscation │ │ +│ │ (Projects) │ │ (Memory) │ │ Engine │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 🚀 البدء السريع + +### التثبيت +```bash +# استنساخ المستودع +cd /home/ubuntu/hacking_factory + +# تثبيت المكتبات +npm install + +# تثبيت ChromaDB +sudo pip3 install chromadb + +# تشغيل الخادم +npm run dev +``` + +### إنشاء مشروع جديد +```typescript +const project = await trpc.projects.create.mutate({ + name: "My Security Project", + mode: "loop", + contentType: "code", + originalPrompt: "Generate a secure authentication system", +}); +``` + +### تشغيل المصنع +```typescript +await trpc.projects.runFactory.mutate({ + projectId: project.projectId, + prompt: project.originalPrompt, + targetScore: 90, +}); +``` + +### مراقبة التقدم +```typescript +const iterations = await trpc.projects.getIterations.query({ + projectId: project.projectId, +}); + +// عرض الرسم البياني في Dashboard +``` + +--- + +## 📁 هيكل الملفات الجديدة + +``` +server/ +├── sandbox.ts # بيئة الفحص المعزولة +├── factory.ts # محرك التكرار الذاتي +├── memory.ts # الذاكرة المتجهة +├── obfuscation.ts # محرك التشفير +├── notifications.ts # نظام الإشعارات +└── routers/ + └── projects.ts # مسارات المشاريع المحدثة + +client/src/ +├── components/ +│ ├── IterationChart.tsx # رسم بياني التكرارات +│ ├── CodeEditor.tsx # محرر الأكواد +│ └── PayloadLibrary.tsx # مكتبة القوالب +└── pages/ + └── Dashboard.tsx # لوحة التحكم المحدثة +``` + +--- + +## 🔄 سير العمل الكامل + +``` +1. إنشاء مشروع جديد + ↓ +2. تشغيل المصنع (runFactory) + ↓ +3. البحث في الذاكرة عن حلول مشابهة + ↓ +4. توليد الكود الأول (Qwen) + ↓ +5. فحص ديناميكي (Sandbox) + ↓ +6. تقييم شامل (DeepSeek) + ↓ +7. حفظ النتيجة في قاعدة البيانات + ↓ +8. هل الدرجة ≥ الهدف؟ + ├─ نعم → حفظ في الذاكرة + إشعار النجاح + └─ لا → العودة للخطوة 4 (تحسين الكود) +``` + +--- + +## 🎨 واجهة المستخدم + +### لوحة التحكم (Dashboard) +- عرض إحصائيات المشاريع النشطة والمكتملة +- رسم بياني تفاعلي لتقدم التكرارات +- قائمة المشاريع مع الحالة والدرجات + +### محرر الأكواد (Code Editor) +- تحرير الأكواد بصيغة Python/JavaScript +- نسخ سريع للأكواد +- تشغيل فوري للاختبار + +### مكتبة القوالب (Payload Library) +- عرض الحلول الناجحة السابقة +- البحث والتصفية حسب الوسوم +- تطبيق التشفير بمستويات مختلفة + +--- + +## 🔐 الأمان + +- جميع الأكواد تُشغل في بيئة معزولة (Sandbox) +- لا توجد وصول مباشر للنظام الرئيسي +- التحقق من صلاحيات المستخدم على جميع المسارات +- تشفير الأكواد قبل التخزين (اختياري) + +--- + +## 📈 الأداء + +- استخدام الخيط الخلفي (Background) لتشغيل المصنع +- تخزين النتائج في الذاكرة المتجهة لتسريع البحث +- تقليل وقت التوليد للمشاكل المتكررة بـ 50-70% + +--- + +## 🔮 التطويرات المستقبلية + +- [ ] دعم نماذج استدلال متقدمة (OpenAI o1, DeepSeek-R1) +- [ ] تكامل Docker الكامل للفحص الديناميكي +- [ ] نظام التنبيهات عبر WebSocket +- [ ] تصدير النتائج بصيغ متعددة (PDF, JSON, CSV) +- [ ] واجهة إدارة متقدمة للمشاريع +- [ ] تحليل الأداء والإحصائيات المتقدمة + +--- + +## 📞 الدعم + +للمزيد من المعلومات، راجع: +- `DEVELOPMENT_GUIDE.md` - دليل التطوير الشامل +- `todo.md` - قائمة المهام والتقدم +- `server/` - كود الخادم +- `client/src/` - كود الواجهة الأمامية + +--- + +**آخر تحديث:** 25 يناير 2026 +**الإصدار:** v2.0 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000000000000000000000000000000000000..fded2ada8f1aabf485e8c97c6594e6463d9c6ebc --- /dev/null +++ b/client/index.html @@ -0,0 +1,26 @@ + + + + + + + Hacking Factory Professional UI + + + + +
+ + + + + diff --git a/client/public/.gitkeep b/client/public/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/client/public/__manus__/debug-collector.js b/client/public/__manus__/debug-collector.js new file mode 100644 index 0000000000000000000000000000000000000000..050455560719c33e2e77041a1db51202aa034f45 --- /dev/null +++ b/client/public/__manus__/debug-collector.js @@ -0,0 +1,821 @@ +/** + * Manus Debug Collector (agent-friendly) + * + * Captures: + * 1) Console logs + * 2) Network requests (fetch + XHR) + * 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.) + * + * Data is periodically sent to /__manus__/logs + * Note: uiEvents are mirrored to sessionEvents for sessionReplay.log + */ +(function () { + "use strict"; + + // Prevent double initialization + if (window.__MANUS_DEBUG_COLLECTOR__) return; + + // ========================================================================== + // Configuration + // ========================================================================== + const CONFIG = { + reportEndpoint: "/__manus__/logs", + bufferSize: { + console: 500, + network: 200, + // semantic, agent-friendly UI events + ui: 500, + }, + reportInterval: 2000, + sensitiveFields: [ + "password", + "token", + "secret", + "key", + "authorization", + "cookie", + "session", + ], + maxBodyLength: 10240, + // UI event logging privacy policy: + // - inputs matching sensitiveFields or type=password are masked by default + // - non-sensitive inputs log up to 200 chars + uiInputMaxLen: 200, + uiTextMaxLen: 80, + // Scroll throttling: minimum ms between scroll events + scrollThrottleMs: 500, + }; + + // ========================================================================== + // Storage + // ========================================================================== + const store = { + consoleLogs: [], + networkRequests: [], + uiEvents: [], + lastReportTime: Date.now(), + lastScrollTime: 0, + }; + + // ========================================================================== + // Utility Functions + // ========================================================================== + + function sanitizeValue(value, depth) { + if (depth === void 0) depth = 0; + if (depth > 5) return "[Max Depth]"; + if (value === null) return null; + if (value === undefined) return undefined; + + if (typeof value === "string") { + return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value; + } + + if (typeof value !== "object") return value; + + if (Array.isArray(value)) { + return value.slice(0, 100).map(function (v) { + return sanitizeValue(v, depth + 1); + }); + } + + var sanitized = {}; + for (var k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + var isSensitive = CONFIG.sensitiveFields.some(function (f) { + return k.toLowerCase().indexOf(f) !== -1; + }); + if (isSensitive) { + sanitized[k] = "[REDACTED]"; + } else { + sanitized[k] = sanitizeValue(value[k], depth + 1); + } + } + } + return sanitized; + } + + function formatArg(arg) { + try { + if (arg instanceof Error) { + return { type: "Error", message: arg.message, stack: arg.stack }; + } + if (typeof arg === "object") return sanitizeValue(arg); + return String(arg); + } catch (e) { + return "[Unserializable]"; + } + } + + function formatArgs(args) { + var result = []; + for (var i = 0; i < args.length; i++) result.push(formatArg(args[i])); + return result; + } + + function pruneBuffer(buffer, maxSize) { + if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize); + } + + function tryParseJson(str) { + if (typeof str !== "string") return str; + try { + return JSON.parse(str); + } catch (e) { + return str; + } + } + + // ========================================================================== + // Semantic UI Event Logging (agent-friendly) + // ========================================================================== + + function shouldIgnoreTarget(target) { + try { + if (!target || !(target instanceof Element)) return false; + return !!target.closest(".manus-no-record"); + } catch (e) { + return false; + } + } + + function compactText(s, maxLen) { + try { + var t = (s || "").trim().replace(/\s+/g, " "); + if (!t) return ""; + return t.length > maxLen ? t.slice(0, maxLen) + "…" : t; + } catch (e) { + return ""; + } + } + + function elText(el) { + try { + var t = el.innerText || el.textContent || ""; + return compactText(t, CONFIG.uiTextMaxLen); + } catch (e) { + return ""; + } + } + + function describeElement(el) { + if (!el || !(el instanceof Element)) return null; + + var getAttr = function (name) { + return el.getAttribute(name); + }; + + var tag = el.tagName ? el.tagName.toLowerCase() : null; + var id = el.id || null; + var name = getAttr("name") || null; + var role = getAttr("role") || null; + var ariaLabel = getAttr("aria-label") || null; + + var dataLoc = getAttr("data-loc") || null; + var testId = + getAttr("data-testid") || + getAttr("data-test-id") || + getAttr("data-test") || + null; + + var type = tag === "input" ? (getAttr("type") || "text") : null; + var href = tag === "a" ? getAttr("href") || null : null; + + // a small, stable hint for agents (avoid building full CSS paths) + var selectorHint = null; + if (testId) selectorHint = '[data-testid="' + testId + '"]'; + else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]'; + else if (id) selectorHint = "#" + id; + else selectorHint = tag || "unknown"; + + return { + tag: tag, + id: id, + name: name, + type: type, + role: role, + ariaLabel: ariaLabel, + testId: testId, + dataLoc: dataLoc, + href: href, + text: elText(el), + selectorHint: selectorHint, + }; + } + + function isSensitiveField(el) { + if (!el || !(el instanceof Element)) return false; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea") return false; + + var type = (el.getAttribute("type") || "").toLowerCase(); + if (type === "password") return true; + + var name = (el.getAttribute("name") || "").toLowerCase(); + var id = (el.id || "").toLowerCase(); + + return CONFIG.sensitiveFields.some(function (f) { + return name.indexOf(f) !== -1 || id.indexOf(f) !== -1; + }); + } + + function getInputValueSafe(el) { + if (!el || !(el instanceof Element)) return null; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea" && tag !== "select") return null; + + var v = ""; + try { + v = el.value != null ? String(el.value) : ""; + } catch (e) { + v = ""; + } + + if (isSensitiveField(el)) return { masked: true, length: v.length }; + + if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…"; + return v; + } + + function logUiEvent(kind, payload) { + var entry = { + timestamp: Date.now(), + kind: kind, + url: location.href, + viewport: { width: window.innerWidth, height: window.innerHeight }, + payload: sanitizeValue(payload), + }; + store.uiEvents.push(entry); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + } + + function installUiEventListeners() { + // Clicks + document.addEventListener( + "click", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("click", { + target: describeElement(t), + x: e.clientX, + y: e.clientY, + }); + }, + true + ); + + // Typing "commit" events + document.addEventListener( + "change", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("change", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + document.addEventListener( + "focusin", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusin", { target: describeElement(t) }); + }, + true + ); + + document.addEventListener( + "focusout", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusout", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + // Enter/Escape are useful for form flows & modals + document.addEventListener( + "keydown", + function (e) { + if (e.key !== "Enter" && e.key !== "Escape") return; + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("keydown", { key: e.key, target: describeElement(t) }); + }, + true + ); + + // Form submissions + document.addEventListener( + "submit", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("submit", { target: describeElement(t) }); + }, + true + ); + + // Throttled scroll events + window.addEventListener( + "scroll", + function () { + var now = Date.now(); + if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return; + store.lastScrollTime = now; + + logUiEvent("scroll", { + scrollX: window.scrollX, + scrollY: window.scrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }); + }, + { passive: true } + ); + + // Navigation tracking for SPAs + function nav(reason) { + logUiEvent("navigate", { reason: reason }); + } + + var origPush = history.pushState; + history.pushState = function () { + origPush.apply(this, arguments); + nav("pushState"); + }; + + var origReplace = history.replaceState; + history.replaceState = function () { + origReplace.apply(this, arguments); + nav("replaceState"); + }; + + window.addEventListener("popstate", function () { + nav("popstate"); + }); + window.addEventListener("hashchange", function () { + nav("hashchange"); + }); + } + + // ========================================================================== + // Console Interception + // ========================================================================== + + var originalConsole = { + log: console.log.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + ["log", "debug", "info", "warn", "error"].forEach(function (method) { + console[method] = function () { + var args = Array.prototype.slice.call(arguments); + + var entry = { + timestamp: Date.now(), + level: method.toUpperCase(), + args: formatArgs(args), + stack: method === "error" ? new Error().stack : null, + }; + + store.consoleLogs.push(entry); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + originalConsole[method].apply(console, args); + }; + }); + + window.addEventListener("error", function (event) { + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UncaughtError", + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }, + ], + stack: event.error ? event.error.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + // Mark an error moment in UI event stream for agents + logUiEvent("error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + }); + + window.addEventListener("unhandledrejection", function (event) { + var reason = event.reason; + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UnhandledRejection", + reason: reason && reason.message ? reason.message : String(reason), + stack: reason && reason.stack ? reason.stack : null, + }, + ], + stack: reason && reason.stack ? reason.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + logUiEvent("unhandledrejection", { + reason: reason && reason.message ? reason.message : String(reason), + }); + }); + + // ========================================================================== + // Fetch Interception + // ========================================================================== + + var originalFetch = window.fetch.bind(window); + + window.fetch = function (input, init) { + init = init || {}; + var startTime = Date.now(); + // Handle string, Request object, or URL object + var url = typeof input === "string" + ? input + : (input && (input.url || input.href || String(input))) || ""; + var method = init.method || (input && input.method) || "GET"; + + // Don't intercept internal requests + if (url.indexOf("/__manus__/") === 0) { + return originalFetch(input, init); + } + + // Safely parse headers (avoid breaking if headers format is invalid) + var requestHeaders = {}; + try { + if (init.headers) { + requestHeaders = Object.fromEntries(new Headers(init.headers).entries()); + } + } catch (e) { + requestHeaders = { _parseError: true }; + } + + var entry = { + timestamp: startTime, + type: "fetch", + method: method.toUpperCase(), + url: url, + request: { + headers: requestHeaders, + body: init.body ? sanitizeValue(tryParseJson(init.body)) : null, + }, + response: null, + duration: null, + error: null, + }; + + return originalFetch(input, init) + .then(function (response) { + entry.duration = Date.now() - startTime; + + var contentType = (response.headers.get("content-type") || "").toLowerCase(); + var contentLength = response.headers.get("content-length"); + + entry.response = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: null, + }; + + // Semantic network hint for agents on failures (sync, no need to wait for body) + if (response.status >= 400) { + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + status: response.status, + statusText: response.statusText, + }); + } + + // Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + if (isStreaming) { + entry.response.body = "[Streaming response - not captured]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for large responses to avoid memory issues + if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) { + entry.response.body = "[Response too large: " + contentLength + " bytes]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + if (isBinary) { + entry.response.body = "[Binary content: " + contentType + "]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // For text responses, clone and read body in background + var clonedResponse = response.clone(); + + // Async: read body in background, don't block the response + clonedResponse + .text() + .then(function (text) { + if (text.length <= CONFIG.maxBodyLength) { + entry.response.body = sanitizeValue(tryParseJson(text)); + } else { + entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } + }) + .catch(function () { + entry.response.body = "[Unable to read body]"; + }) + .finally(function () { + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + }); + + // Return response immediately, don't wait for body reading + return response; + }) + .catch(function (error) { + entry.duration = Date.now() - startTime; + entry.error = { message: error.message, stack: error.stack }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + message: error.message, + }); + + throw error; + }); + }; + + // ========================================================================== + // XHR Interception + // ========================================================================== + + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this._manusData = { + method: (method || "GET").toUpperCase(), + url: url, + startTime: null, + }; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + var xhr = this; + + if ( + xhr._manusData && + xhr._manusData.url && + xhr._manusData.url.indexOf("/__manus__/") !== 0 + ) { + xhr._manusData.startTime = Date.now(); + xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null; + + xhr.addEventListener("load", function () { + var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase(); + var responseBody = null; + + // Skip body capture for streaming responses + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + + if (isStreaming) { + responseBody = "[Streaming response - not captured]"; + } else if (isBinary) { + responseBody = "[Binary content: " + contentType + "]"; + } else { + // Safe to read responseText for text responses + try { + var text = xhr.responseText || ""; + if (text.length > CONFIG.maxBodyLength) { + responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } else { + responseBody = sanitizeValue(tryParseJson(text)); + } + } catch (e) { + // responseText may throw for non-text responses + responseBody = "[Unable to read response: " + e.message + "]"; + } + } + + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: { + status: xhr.status, + statusText: xhr.statusText, + body: responseBody, + }, + duration: Date.now() - xhr._manusData.startTime, + error: null, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + if (entry.response && entry.response.status >= 400) { + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + status: entry.response.status, + statusText: entry.response.statusText, + }); + } + }); + + xhr.addEventListener("error", function () { + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: null, + duration: Date.now() - xhr._manusData.startTime, + error: { message: "Network error" }, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + message: "Network error", + }); + }); + } + + return originalXHRSend.apply(this, arguments); + }; + + // ========================================================================== + // Data Reporting + // ========================================================================== + + function reportLogs() { + var consoleLogs = store.consoleLogs.splice(0); + var networkRequests = store.networkRequests.splice(0); + var uiEvents = store.uiEvents.splice(0); + + // Skip if no new data + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return Promise.resolve(); + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + // agent-friendly semantic events + uiEvents: uiEvents, + }; + + return originalFetch(CONFIG.reportEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(function () { + // Put data back on failure (but respect limits) + store.consoleLogs = consoleLogs.concat(store.consoleLogs); + store.networkRequests = networkRequests.concat(store.networkRequests); + store.uiEvents = uiEvents.concat(store.uiEvents); + + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + }); + } + + // Periodic reporting + setInterval(reportLogs, CONFIG.reportInterval); + + // Report on page unload + window.addEventListener("beforeunload", function () { + var consoleLogs = store.consoleLogs; + var networkRequests = store.networkRequests; + var uiEvents = store.uiEvents; + + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return; + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + uiEvents: uiEvents, + }; + + if (navigator.sendBeacon) { + var payloadStr = JSON.stringify(payload); + // sendBeacon has ~64KB limit, truncate if too large + var MAX_BEACON_SIZE = 60000; // Leave some margin + if (payloadStr.length > MAX_BEACON_SIZE) { + // Prioritize: keep recent events, drop older logs + var truncatedPayload = { + timestamp: Date.now(), + consoleLogs: consoleLogs.slice(-50), + networkRequests: networkRequests.slice(-20), + sessionEvents: uiEvents.slice(-100), + uiEvents: uiEvents.slice(-100), + _truncated: true, + }; + payloadStr = JSON.stringify(truncatedPayload); + } + navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr); + } + }); + + // ========================================================================== + // Initialization + // ========================================================================== + + // Install semantic UI listeners ASAP + try { + installUiEventListeners(); + } catch (e) { + console.warn("[Manus] Failed to install UI listeners:", e); + } + + // Mark as initialized + window.__MANUS_DEBUG_COLLECTOR__ = { + version: "2.0-no-rrweb", + store: store, + forceReport: reportLogs, + }; + + console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)"); +})(); diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bebd70437d3b063fead1b8b0a9f408594df7a98d --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,46 @@ +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import NotFound from "@/pages/NotFound"; +import { Route, Switch } from "wouter"; +import ErrorBoundary from "./components/ErrorBoundary"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import Home from "./pages/Home"; +import Dashboard from "./pages/Dashboard"; +import ChatInterface from "./pages/ChatInterface"; + +function Router() { + // make sure to consider if you need authentication for certain routes + return ( + + + + + + {/* Final fallback route */} + + + ); +} + +// NOTE: About Theme +// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css +// to keep consistent foreground/background color across components +// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/client/src/_core/hooks/useAuth.ts b/client/src/_core/hooks/useAuth.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcef9bd849ec892c16cda7874d4c93a4b77c9f7c --- /dev/null +++ b/client/src/_core/hooks/useAuth.ts @@ -0,0 +1,84 @@ +import { getLoginUrl } from "@/const"; +import { trpc } from "@/lib/trpc"; +import { TRPCClientError } from "@trpc/client"; +import { useCallback, useEffect, useMemo } from "react"; + +type UseAuthOptions = { + redirectOnUnauthenticated?: boolean; + redirectPath?: string; +}; + +export function useAuth(options?: UseAuthOptions) { + const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } = + options ?? {}; + const utils = trpc.useUtils(); + + const meQuery = trpc.auth.me.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); + + const logoutMutation = trpc.auth.logout.useMutation({ + onSuccess: () => { + utils.auth.me.setData(undefined, null); + }, + }); + + const logout = useCallback(async () => { + try { + await logoutMutation.mutateAsync(); + } catch (error: unknown) { + if ( + error instanceof TRPCClientError && + error.data?.code === "UNAUTHORIZED" + ) { + return; + } + throw error; + } finally { + utils.auth.me.setData(undefined, null); + await utils.auth.me.invalidate(); + } + }, [logoutMutation, utils]); + + const state = useMemo(() => { + localStorage.setItem( + "manus-runtime-user-info", + JSON.stringify(meQuery.data) + ); + return { + user: meQuery.data ?? null, + loading: meQuery.isLoading || logoutMutation.isPending, + error: meQuery.error ?? logoutMutation.error ?? null, + isAuthenticated: Boolean(meQuery.data), + }; + }, [ + meQuery.data, + meQuery.error, + meQuery.isLoading, + logoutMutation.error, + logoutMutation.isPending, + ]); + + useEffect(() => { + if (!redirectOnUnauthenticated) return; + if (meQuery.isLoading || logoutMutation.isPending) return; + if (state.user) return; + if (typeof window === "undefined") return; + if (window.location.pathname === redirectPath) return; + + window.location.href = redirectPath + }, [ + redirectOnUnauthenticated, + redirectPath, + logoutMutation.isPending, + meQuery.isLoading, + state.user, + ]); + + return { + ...state, + refresh: () => meQuery.refetch(), + logout, + }; +} diff --git a/client/src/components/AIChatBox.tsx b/client/src/components/AIChatBox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1c00871fcd7099a42c6a73b8f96d1ec398d01bd2 --- /dev/null +++ b/client/src/components/AIChatBox.tsx @@ -0,0 +1,335 @@ +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Loader2, Send, User, Sparkles } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { Streamdown } from "streamdown"; + +/** + * Message type matching server-side LLM Message interface + */ +export type Message = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export type AIChatBoxProps = { + /** + * Messages array to display in the chat. + * Should match the format used by invokeLLM on the server. + */ + messages: Message[]; + + /** + * Callback when user sends a message. + * Typically you'll call a tRPC mutation here to invoke the LLM. + */ + onSendMessage: (content: string) => void; + + /** + * Whether the AI is currently generating a response + */ + isLoading?: boolean; + + /** + * Placeholder text for the input field + */ + placeholder?: string; + + /** + * Custom className for the container + */ + className?: string; + + /** + * Height of the chat box (default: 600px) + */ + height?: string | number; + + /** + * Empty state message to display when no messages + */ + emptyStateMessage?: string; + + /** + * Suggested prompts to display in empty state + * Click to send directly + */ + suggestedPrompts?: string[]; +}; + +/** + * A ready-to-use AI chat box component that integrates with the LLM system. + * + * Features: + * - Matches server-side Message interface for seamless integration + * - Markdown rendering with Streamdown + * - Auto-scrolls to latest message + * - Loading states + * - Uses global theme colors from index.css + * + * @example + * ```tsx + * const ChatPage = () => { + * const [messages, setMessages] = useState([ + * { role: "system", content: "You are a helpful assistant." } + * ]); + * + * const chatMutation = trpc.ai.chat.useMutation({ + * onSuccess: (response) => { + * // Assuming your tRPC endpoint returns the AI response as a string + * setMessages(prev => [...prev, { + * role: "assistant", + * content: response + * }]); + * }, + * onError: (error) => { + * console.error("Chat error:", error); + * // Optionally show error message to user + * } + * }); + * + * const handleSend = (content: string) => { + * const newMessages = [...messages, { role: "user", content }]; + * setMessages(newMessages); + * chatMutation.mutate({ messages: newMessages }); + * }; + * + * return ( + * + * ); + * }; + * ``` + */ +export function AIChatBox({ + messages, + onSendMessage, + isLoading = false, + placeholder = "Type your message...", + className, + height = "600px", + emptyStateMessage = "Start a conversation with AI", + suggestedPrompts, +}: AIChatBoxProps) { + const [input, setInput] = useState(""); + const scrollAreaRef = useRef(null); + const containerRef = useRef(null); + const inputAreaRef = useRef(null); + const textareaRef = useRef(null); + + // Filter out system messages + const displayMessages = messages.filter((msg) => msg.role !== "system"); + + // Calculate min-height for last assistant message to push user message to top + const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0); + + useEffect(() => { + if (containerRef.current && inputAreaRef.current) { + const containerHeight = containerRef.current.offsetHeight; + const inputHeight = inputAreaRef.current.offsetHeight; + const scrollAreaHeight = containerHeight - inputHeight; + + // Reserve space for: + // - padding (p-4 = 32px top+bottom) + // - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px + // Note: margin-bottom is not counted because it naturally pushes the assistant message down + const userMessageReservedHeight = 56; + const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight; + + setMinHeightForLastMessage(Math.max(0, calculatedHeight)); + } + }, []); + + // Scroll to bottom helper function with smooth animation + const scrollToBottom = () => { + const viewport = scrollAreaRef.current?.querySelector( + '[data-radix-scroll-area-viewport]' + ) as HTMLDivElement; + + if (viewport) { + requestAnimationFrame(() => { + viewport.scrollTo({ + top: viewport.scrollHeight, + behavior: 'smooth' + }); + }); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedInput = input.trim(); + if (!trimmedInput || isLoading) return; + + onSendMessage(trimmedInput); + setInput(""); + + // Scroll immediately after sending + scrollToBottom(); + + // Keep focus on input + textareaRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + return ( +
+ {/* Messages Area */} +
+ {displayMessages.length === 0 ? ( +
+
+
+ +

{emptyStateMessage}

+
+ + {suggestedPrompts && suggestedPrompts.length > 0 && ( +
+ {suggestedPrompts.map((prompt, index) => ( + + ))} +
+ )} +
+
+ ) : ( + +
+ {displayMessages.map((message, index) => { + // Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it) + const isLastMessage = index === displayMessages.length - 1; + const shouldApplyMinHeight = + isLastMessage && !isLoading && minHeightForLastMessage > 0; + + return ( +
+ {message.role === "assistant" && ( +
+ +
+ )} + +
+ {message.role === "assistant" ? ( +
+ {message.content} +
+ ) : ( +

+ {message.content} +

+ )} +
+ + {message.role === "user" && ( +
+ +
+ )} +
+ ); + })} + + {isLoading && ( +
0 + ? { minHeight: `${minHeightForLastMessage}px` } + : undefined + } + > +
+ +
+
+ +
+
+ )} +
+
+ )} +
+ + {/* Input Area */} +
+