Compare commits
5 Commits
25529a24b8
...
epic-ux-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402e88b788 | ||
|
|
c2a4c22e5f | ||
|
|
2ef16f8a2c | ||
|
|
c4c8f6a417 | ||
|
|
389f85937a |
47
.playwright-mcp/console-2026-04-19T18-53-32-857Z.log
Normal file
@@ -0,0 +1,47 @@
|
||||
[ 242ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:3000/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2F:0
|
||||
[ 274ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 312ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 343ms] [LOG] [Prisma] Models loaded: @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 426ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 437ms] [LOG] [Fast Refresh] done in 112ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 180853ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 180883ms] [LOG] [Fast Refresh] done in 131ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 205199ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 205245ms] [LOG] [Fast Refresh] done in 50ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 205472ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 205472ms] [LOG] [Fast Refresh] done in 104ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 218086ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 218512ms] [LOG] [Fast Refresh] done in 150ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 218619ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 218629ms] [LOG] [Fast Refresh] done in 111ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 226896ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 226973ms] [LOG] [Fast Refresh] done in 41ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 250168ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 250197ms] [LOG] [Fast Refresh] done in 44ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300156ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300662ms] [LOG] [Fast Refresh] done in 206ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300662ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300673ms] [LOG] [Fast Refresh] done in 9ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300673ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300673ms] [LOG] [Fast Refresh] done in 1ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300674ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300674ms] [LOG] [Fast Refresh] done in 0ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300775ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 300797ms] [LOG] [Fast Refresh] done in 123ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 330315ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 330346ms] [LOG] [Fast Refresh] done in 132ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 494083ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 494099ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 494128ms] [LOG] [Prisma] Models loaded: @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 494533ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 494534ms] [LOG] [Fast Refresh] done in 312ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 583684ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 583685ms] [LOG] [Fast Refresh] done in 102ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 612080ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 612106ms] [LOG] [Fast Refresh] done in 50ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 620251ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 620280ms] [LOG] [Fast Refresh] done in 42ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 628266ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 628300ms] [LOG] [Fast Refresh] done in 48ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 628402ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
[ 628423ms] [LOG] [Fast Refresh] done in 122ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||
21
.playwright-mcp/page-2026-04-19T18-53-33-247Z.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- main [ref=e4]:
|
||||
- generic [ref=e7]:
|
||||
- heading "Sign in to your account" [level=1] [ref=e8]
|
||||
- generic [ref=e9]:
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]: Email
|
||||
- textbox "Email" [ref=e13]:
|
||||
- /placeholder: Enter your email address
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e15]: Password
|
||||
- textbox "Password" [ref=e17]:
|
||||
- /placeholder: Enter your password
|
||||
- link "Forgot password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
- button "Sign In" [ref=e20]
|
||||
- generic [ref=e22]:
|
||||
- text: Don't have an account?
|
||||
- link "Sign Up" [ref=e23] [cursor=pointer]:
|
||||
- /url: /register
|
||||
- region "Notifications alt+T"
|
||||
@@ -185,20 +185,20 @@ export function AgentsPageClient({
|
||||
{/* Agents grid */}
|
||||
{agents.length > 0 && (
|
||||
<div className="mb-10">
|
||||
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
||||
{t('agents.myAgents')}
|
||||
</h3>
|
||||
|
||||
{/* Search and filter */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 w-full sm:max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('agents.searchPlaceholder')}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm bg-white border border-slate-200 rounded-lg outline-none focus:border-primary/40 focus:ring-2 focus:ring-primary/10 transition-all"
|
||||
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-lg outline-none focus:border-primary/40 focus:ring-2 focus:ring-primary/10 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
@@ -209,7 +209,7 @@ export function AgentsPageClient({
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
|
||||
typeFilter === opt.value
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
@@ -232,8 +232,8 @@ export function AgentsPageClient({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="w-10 h-10 text-slate-300 mb-3" />
|
||||
<p className="text-sm text-slate-400">{t('agents.noResults')}</p>
|
||||
<Search className="w-10 h-10 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t('agents.noResults')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -242,9 +242,9 @@ export function AgentsPageClient({
|
||||
{/* Empty state */}
|
||||
{agents.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center mb-10">
|
||||
<Bot className="w-16 h-16 text-slate-300 mb-4" />
|
||||
<h3 className="text-lg font-medium text-slate-500 mb-2">{t('agents.noAgents')}</h3>
|
||||
<p className="text-sm text-slate-400 max-w-sm">
|
||||
<Bot className="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-medium text-muted-foreground mb-2">{t('agents.noAgents')}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
{t('agents.noAgentsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function AgentsPage() {
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50/50">
|
||||
<div className="flex-1 flex flex-col h-full bg-background">
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AgentsPageClient
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ChatContainer } from '@/components/chat/chat-container'
|
||||
import { getConversations } from '@/app/actions/chat-actions'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat IA | Memento',
|
||||
@@ -17,19 +18,26 @@ export default async function ChatPage() {
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch initial data
|
||||
const [conversations, notebooks] = await Promise.all([
|
||||
const [conversations, notebooks, config] = await Promise.all([
|
||||
getConversations(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
}),
|
||||
getSystemConfig(),
|
||||
])
|
||||
|
||||
// Check if web search tools are configured
|
||||
const webSearchAvailable = !!(
|
||||
config.WEB_SEARCH_PROVIDER || config.BRAVE_SEARCH_API_KEY || config.SEARXNG_URL || config.JINA_API_KEY
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
|
||||
<ChatContainer
|
||||
initialConversations={conversations}
|
||||
notebooks={notebooks}
|
||||
<ChatContainer
|
||||
initialConversations={conversations}
|
||||
notebooks={notebooks}
|
||||
webSearchAvailable={webSearchAvailable}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -435,6 +435,7 @@ export async function createNote(data: {
|
||||
isMarkdown?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
autoGenerated?: boolean
|
||||
aiProvider?: string
|
||||
notebookId?: string | undefined // Assign note to a notebook if provided
|
||||
skipRevalidation?: boolean // Option to prevent full page refresh for smooth optimistic UI updates
|
||||
}) {
|
||||
@@ -459,6 +460,7 @@ export async function createNote(data: {
|
||||
isMarkdown: data.isMarkdown || false,
|
||||
size: data.size || 'small',
|
||||
autoGenerated: data.autoGenerated || null,
|
||||
aiProvider: data.aiProvider || null,
|
||||
notebookId: data.notebookId || null,
|
||||
}
|
||||
})
|
||||
@@ -867,7 +869,7 @@ export async function togglePin(id: string, isPinned: boolean) { return updateNo
|
||||
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
|
||||
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) }
|
||||
export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) }
|
||||
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null }) }
|
||||
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null, aiProvider: null }) }
|
||||
|
||||
// Update note size WITHOUT revalidation - client uses optimistic updates
|
||||
export async function updateSize(id: string, size: 'small' | 'medium' | 'large') {
|
||||
@@ -941,8 +943,16 @@ export async function updateFullOrderWithoutRevalidation(ids: string[]) {
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
const userId = session.user.id;
|
||||
try {
|
||||
const updates = ids.map((id: string, index: number) =>
|
||||
prisma.note.update({ where: { id, userId }, data: { order: index } })
|
||||
// Verify all notes belong to the user before updating
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: ids }, userId },
|
||||
select: { id: true },
|
||||
})
|
||||
const ownedIds = new Set(notes.map(n => n.id))
|
||||
const validIds = ids.filter(id => ownedIds.has(id))
|
||||
|
||||
const updates = validIds.map((id: string, index: number) =>
|
||||
prisma.note.update({ where: { id }, data: { order: index } })
|
||||
)
|
||||
await prisma.$transaction(updates)
|
||||
return { success: true }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
@@ -50,8 +51,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Get AI provider
|
||||
const config = await prisma.systemConfig.findFirst()
|
||||
const provider = getAIProvider(config || undefined)
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
// Build fusion prompt
|
||||
const notesDescriptions = notes.map((note, index) => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { semanticSearchService } from '@/lib/ai/services/semantic-search.service
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
import { toolRegistry } from '@/lib/ai/tools'
|
||||
import { stepCountIs } from 'ai'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
@@ -45,11 +47,12 @@ export async function POST(req: Request) {
|
||||
|
||||
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
|
||||
const body = await req.json()
|
||||
const { messages: rawMessages, conversationId, notebookId, language } = body as {
|
||||
const { messages: rawMessages, conversationId, notebookId, language, webSearch } = body as {
|
||||
messages: UIMessage[]
|
||||
conversationId?: string
|
||||
notebookId?: string
|
||||
language?: string
|
||||
webSearch?: boolean
|
||||
}
|
||||
|
||||
// Convert UIMessages to CoreMessages for streamText
|
||||
@@ -143,7 +146,19 @@ export async function POST(req: Request) {
|
||||
- Natural tone, neither corporate nor too casual.
|
||||
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly.
|
||||
- No upsell questions at the end ("Would you like me to...", "Do you want..."). If you have useful additional info, just give it.
|
||||
- If the user says "Momento" they mean Memento (this app).`,
|
||||
- If the user says "Momento" they mean Memento (this app).
|
||||
|
||||
## Available tools
|
||||
You have access to these tools for deeper research:
|
||||
- **note_search**: Search the user's notes by keyword or meaning. Use when the initial context above is insufficient or when the user asks about specific content in their notes. If a notebook is selected, pass its ID to restrict results.
|
||||
- **note_read**: Read a specific note by ID. Use when note_search returns a note you need the full content of.
|
||||
- **web_search**: Search the web for information. Use when the user asks about something not in their notes.
|
||||
- **web_scrape**: Scrape a web page and return its content as markdown. Use when web_search returns a URL you need to read.
|
||||
|
||||
## Tool usage rules
|
||||
- You already have context from the user's notes above. Only use tools if you need more specific or additional information.
|
||||
- Never invent note IDs, URLs, or notebook IDs. Use the IDs provided in the context or from tool results.
|
||||
- For simple conversational questions (greetings, opinions, general knowledge), answer directly without using any tools.`,
|
||||
},
|
||||
fr: {
|
||||
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule. Si les notes ne couvrent pas le sujet, dis-le et complète avec tes connaissances générales.`,
|
||||
@@ -159,7 +174,19 @@ export async function POST(req: Request) {
|
||||
- Ton naturel, ni corporate ni trop familier.
|
||||
- Pas de phrase d'intro inutile ("Voici ce que j'ai trouvé", "Basé sur vos notes"). Réponds directement.
|
||||
- Pas de question upsell à la fin ("Souhaitez-vous que je...", "Acceptez-vous que..."). Si tu as une info complémentaire utile, donne-la.
|
||||
- Si l'utilisateur dit "Momento" il parle de Memento (cette application).`,
|
||||
- Si l'utilisateur dit "Momento" il parle de Memento (cette application).
|
||||
|
||||
## Outils disponibles
|
||||
Tu as accès à ces outils pour des recherches approfondies :
|
||||
- **note_search** : Cherche dans les notes de l'utilisateur par mot-clé ou sens. Utilise quand le contexte initial ci-dessus est insuffisant ou quand l'utilisateur demande du contenu spécifique dans ses notes. Si un carnet est sélectionné, passe son ID pour restreindre les résultats.
|
||||
- **note_read** : Lit une note spécifique par son ID. Utilise quand note_search retourne une note dont tu as besoin du contenu complet.
|
||||
- **web_search** : Recherche sur le web. Utilise quand l'utilisateur demande quelque chose qui n'est pas dans ses notes.
|
||||
- **web_scrape** : Scrape une page web et retourne son contenu en markdown. Utilise quand web_search retourne une URL que tu veux lire.
|
||||
|
||||
## Règles d'utilisation des outils
|
||||
- Tu as déjà du contexte des notes de l'utilisateur ci-dessus. Utilise les outils seulement si tu as besoin d'informations plus spécifiques.
|
||||
- N'invente jamais d'IDs de notes, d'URLs ou d'IDs de carnet. Utilise les IDs fournis dans le contexte ou les résultats d'outils.
|
||||
- Pour les questions conversationnelles simples (salutations, opinions, connaissances générales), réponds directement sans utiliser d'outils.`,
|
||||
},
|
||||
fa: {
|
||||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشتها موضوع را پوشش نمیدهند، بگویید و با دانش عمومی خود تکمیل کنید.`,
|
||||
@@ -175,7 +202,18 @@ export async function POST(req: Request) {
|
||||
- لحن طبیعی، نه رسمی بیش از حد و نه خیلی غیررسمی.
|
||||
- بدون جمله مقدمه اضافی. مستقیم پاسخ دهید.
|
||||
- بدون سؤال فروشی در انتها. اگر اطلاعات تکمیلی مفید دارید، مستقیم بدهید.
|
||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.
|
||||
|
||||
## ابزارهای موجود
|
||||
- **note_search**: جستجو در یادداشتهای کاربر با کلیدواژه یا معنی. زمانی استفاده کنید که زمینه اولیه کافی نباشد. اگر دفترچه انتخاب شده، شناسه آن را ارسال کنید.
|
||||
- **note_read**: خواندن یک یادداشت خاص با شناسه. زمانی استفاده کنید که note_search یادداشتی برگرداند که محتوای کامل آن را نیاز دارید.
|
||||
- **web_search**: جستجو در وب. زمانی استفاده کنید که کاربر درباره چیزی خارج از یادداشتهایش میپرسد.
|
||||
- **web_scrape**: استخراج محتوای صفحه وب. زمانی استفاده کنید که web_search نشانیای برگرداند که میخواهید بخوانید.
|
||||
|
||||
## قوانین استفاده از ابزارها
|
||||
- شما از قبل زمینهای از یادداشتهای کاربر دارید. فقط در صورت نیاز به اطلاعات بیشتر از ابزارها استفاده کنید.
|
||||
- هرگز شناسه یادداشت، نشانی یا شناسه دفترچه نسازید. از شناسههای موجود در زمینه یا نتایج ابزار استفاده کنید.
|
||||
- برای سؤالات مکالمهای ساده (سلام، نظرات، دانش عمومی)، مستقیم پاسخ دهید.`,
|
||||
},
|
||||
es: {
|
||||
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis. No copies palabra por palabra — reformula. Si las notas no cubren el tema, dilo y complementa con tu conocimiento general.`,
|
||||
@@ -190,7 +228,18 @@ export async function POST(req: Request) {
|
||||
## Reglas de tono
|
||||
- Tono natural, ni corporativo ni demasiado informal.
|
||||
- Sin frases de introducción innecesarias. Responde directamente.
|
||||
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente.`,
|
||||
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente.
|
||||
|
||||
## Herramientas disponibles
|
||||
- **note_search**: Busca en las notas del usuario por palabra clave o significado. Úsalo cuando el contexto inicial sea insuficiente. Si hay una libreta seleccionada, pasa su ID para restringir los resultados.
|
||||
- **note_read**: Lee una nota específica por su ID. Úsalo cuando note_search devuelva una nota cuyo contenido completo necesites.
|
||||
- **web_search**: Busca en la web. Úsalo cuando el usuario pregunte sobre algo que no está en sus notas.
|
||||
- **web_scrape**: Extrae el contenido de una página web como markdown. Úsalo cuando web_search devuelva una URL que quieras leer.
|
||||
|
||||
## Reglas de uso de herramientas
|
||||
- Ya tienes contexto de las notas del usuario arriba. Solo usa herramientas si necesitas información más específica.
|
||||
- Nunca inventes IDs de notas, URLs o IDs de libreta. Usa los IDs proporcionados en el contexto o en los resultados de herramientas.
|
||||
- Para preguntas conversacionales simples (saludos, opiniones, conocimiento general), responde directamente sin herramientas.`,
|
||||
},
|
||||
de: {
|
||||
contextWithNotes: `## Notizen des Benutzers\n\n${contextNotes}\n\nWenn du Infos aus den obigen Notizen verwendest, zitiere den Titel der Quellnotiz in Klammern. Nicht Wort für Wort kopieren — umformulieren. Wenn die Notizen das Thema nicht abdecken, sag es und ergänze mit deinem Allgemeinwissen.`,
|
||||
@@ -205,7 +254,18 @@ export async function POST(req: Request) {
|
||||
## Tonregeln
|
||||
- Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich.
|
||||
- Keine unnötigen Einleitungssätze. Antworte direkt.
|
||||
- Keine Upsell-Fragen am Ende. Gib nützliche Zusatzinfos einfach direkt.`,
|
||||
- Keine Upsell-Fragen am Ende. Gib nützliche Zusatzinfos einfach direkt.
|
||||
|
||||
## Verfügbare Werkzeuge
|
||||
- **note_search**: Durchsuche die Notizen des Benutzers nach Schlagwort oder Bedeutung. Verwende es, wenn der obige Kontext unzureichend ist. Wenn ein Notizbuch ausgewählt ist, gib dessen ID an, um die Ergebnisse einzuschränken.
|
||||
- **note_read**: Lese eine bestimmte Notiz anhand ihrer ID. Verwende es, wenn note_search eine Notiz zurückgibt, deren vollständigen Inhalt du benötigst.
|
||||
- **web_search**: Suche im Web. Verwende es, wenn der Benutzer nach etwas fragt, das nicht in seinen Notizen steht.
|
||||
- **web_scrape**: Lese eine Webseite und gib den Inhalt als Markdown zurück. Verwende es, wenn web_search eine URL zurückgibt, die du lesen möchtest.
|
||||
|
||||
## Werkzeugregeln
|
||||
- Du hast bereits Kontext aus den Notizen des Benutzers oben. Verwende Werkzeuge nur, wenn du spezifischere Informationen benötigst.
|
||||
- Erfinde niemals Notiz-IDs, URLs oder Notizbuch-IDs. Verwende die im Kontext oder in Werkzeugergebnissen bereitgestellten IDs.
|
||||
- Bei einfachen Gesprächsfragen (Begrüßungen, Meinungen, Allgemeinwissen) antworte direkt ohne Werkzeuge.`,
|
||||
},
|
||||
it: {
|
||||
contextWithNotes: `## Note dell'utente\n\n${contextNotes}\n\nQuando usi informazioni dalle note sopra, cita il titolo della nota fonte tra parentesi. Non copiare parola per parola — riformula. Se le note non coprono l'argomento, dillo e integra con la tua conoscenza generale.`,
|
||||
@@ -220,7 +280,18 @@ export async function POST(req: Request) {
|
||||
## Regole di tono
|
||||
- Tono naturale, né aziendale né troppo informale.
|
||||
- Nessuna frase introduttiva non necessaria. Rispondi direttamente.
|
||||
- Nessuna domanda di upsell alla fine. Se hai informazioni aggiuntive utili, dalle direttamente.`,
|
||||
- Nessuna domanda di upsell alla fine. Se hai informazioni aggiuntive utili, dalle direttamente.
|
||||
|
||||
## Strumenti disponibili
|
||||
- **note_search**: Cerca nelle note dell'utente per parola chiave o significato. Usa quando il contesto iniziale è insufficiente. Se un quaderno è selezionato, passa il suo ID per restringere i risultati.
|
||||
- **note_read**: Leggi una nota specifica per ID. Usa quando note_search restituisce una nota di cui hai bisogno del contenuto completo.
|
||||
- **web_search**: Cerca sul web. Usa quando l'utente chiede qualcosa che non è nelle sue note.
|
||||
- **web_scrape**: Estrai il contenuto di una pagina web come markdown. Usa quando web_search restituisce un URL che vuoi leggere.
|
||||
|
||||
## Regole di utilizzo degli strumenti
|
||||
- Hai già contesto dalle note dell'utente sopra. Usa gli strumenti solo se hai bisogno di informazioni più specifiche.
|
||||
- Non inventare mai ID di note, URL o ID di quaderno. Usa gli ID forniti nel contesto o nei risultati degli strumenti.
|
||||
- Per domande conversazionali semplici (saluti, opinioni, conoscenza generale), rispondi direttamente senza strumenti.`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -260,6 +331,16 @@ ${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds
|
||||
const provider = getChatProvider(config)
|
||||
const model = provider.getModel()
|
||||
|
||||
// 7b. Build chat tools
|
||||
const chatToolContext = {
|
||||
userId,
|
||||
conversationId: conversation.id,
|
||||
notebookId,
|
||||
webSearch: !!webSearch,
|
||||
config,
|
||||
}
|
||||
const chatTools = toolRegistry.buildToolsForChat(chatToolContext)
|
||||
|
||||
// 8. Save user message to DB before streaming
|
||||
if (isNewMessage && lastIncoming) {
|
||||
await prisma.chatMessage.create({
|
||||
@@ -276,6 +357,8 @@ ${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds
|
||||
model,
|
||||
system: systemPrompt,
|
||||
messages: allMessages,
|
||||
tools: chatTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
async onFinish({ text }) {
|
||||
// Save assistant message to DB after streaming completes
|
||||
await prisma.chatMessage.create({
|
||||
|
||||
@@ -52,10 +52,10 @@ interface AgentCardProps {
|
||||
// --- Config ---
|
||||
|
||||
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string }> = {
|
||||
scraper: { icon: Globe, color: 'text-blue-600', bgColor: 'bg-blue-50 border-blue-200' },
|
||||
researcher: { icon: Search, color: 'text-purple-600', bgColor: 'bg-purple-50 border-purple-200' },
|
||||
monitor: { icon: Eye, color: 'text-amber-600', bgColor: 'bg-amber-50 border-amber-200' },
|
||||
custom: { icon: Settings, color: 'text-green-600', bgColor: 'bg-green-50 border-green-200' },
|
||||
scraper: { icon: Globe, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800' },
|
||||
researcher: { icon: Search, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800' },
|
||||
monitor: { icon: Eye, color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800' },
|
||||
custom: { icon: Settings, color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800' },
|
||||
}
|
||||
|
||||
const frequencyKeys: Record<string, string> = {
|
||||
@@ -142,10 +142,10 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 transition-all overflow-hidden
|
||||
${agent.isEnabled ? 'border-slate-200 hover:border-primary/30 hover:shadow-md' : 'border-slate-100 opacity-60'}
|
||||
bg-card rounded-xl border-2 transition-all overflow-hidden
|
||||
${agent.isEnabled ? 'border-border hover:border-primary/30 hover:shadow-md' : 'border-border/50 opacity-60'}
|
||||
`}>
|
||||
<div className={`h-1 ${agent.isEnabled ? 'bg-primary' : 'bg-slate-200'}`} />
|
||||
<div className={`h-1 ${agent.isEnabled ? 'bg-primary' : 'bg-muted'}`} />
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
@@ -155,9 +155,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-slate-800 truncate">{agent.name}</h3>
|
||||
<h3 className="font-semibold text-card-foreground truncate">{agent.name}</h3>
|
||||
{mounted && isNew && (
|
||||
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-emerald-100 text-emerald-700 rounded">
|
||||
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 rounded">
|
||||
{t('agents.newBadge')}
|
||||
</span>
|
||||
)}
|
||||
@@ -171,16 +171,16 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
{agent.isEnabled ? (
|
||||
<ToggleRight className="w-6 h-6 text-primary" />
|
||||
) : (
|
||||
<ToggleLeft className="w-6 h-6 text-slate-300" />
|
||||
<ToggleLeft className="w-6 h-6 text-muted-foreground/40" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<p className="text-xs text-slate-500 mb-3 line-clamp-2">{agent.description}</p>
|
||||
<p className="text-xs text-muted-foreground mb-3 line-clamp-2">{agent.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 mb-3">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
|
||||
@@ -199,9 +199,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
{lastAction && (
|
||||
<div className={`
|
||||
flex items-center gap-1.5 text-xs px-2 py-1 rounded-md mb-3
|
||||
${lastAction.status === 'success' ? 'bg-green-50 text-green-600' : ''}
|
||||
${lastAction.status === 'failure' ? 'bg-red-50 text-red-600' : ''}
|
||||
${lastAction.status === 'running' ? 'bg-blue-50 text-blue-600' : ''}
|
||||
${lastAction.status === 'success' ? 'bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400' : ''}
|
||||
${lastAction.status === 'failure' ? 'bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400' : ''}
|
||||
${lastAction.status === 'running' ? 'bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400' : ''}
|
||||
`}>
|
||||
{lastAction.status === 'success' && <CheckCircle2 className="w-3 h-3" />}
|
||||
{lastAction.status === 'failure' && <XCircle className="w-3 h-3" />}
|
||||
@@ -224,7 +224,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || !agent.isEnabled}
|
||||
className="p-1.5 text-green-600 bg-green-50 rounded-lg hover:bg-green-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="p-1.5 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950 rounded-lg hover:bg-green-100 dark:hover:bg-green-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t('agents.actions.run')}
|
||||
>
|
||||
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
@@ -232,7 +232,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="p-1.5 text-red-500 bg-red-50 rounded-lg hover:bg-red-100 transition-colors disabled:opacity-50"
|
||||
className="p-1.5 text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950 rounded-lg hover:bg-red-100 dark:hover:bg-red-900 transition-colors disabled:opacity-50"
|
||||
title={t('agents.actions.delete')}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
|
||||
@@ -21,7 +21,7 @@ function FieldHelp({ tooltip }: { tooltip: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="inline-flex items-center ml-1 text-slate-300 hover:text-slate-500 transition-colors">
|
||||
<button type="button" className="inline-flex items-center ml-1 text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -61,6 +61,16 @@ const TOOL_PRESETS: Record<string, string[]> = {
|
||||
custom: ['memory_search'],
|
||||
}
|
||||
|
||||
// --- Shared class strings ---
|
||||
const labelCls = 'block text-sm font-medium text-foreground mb-1.5'
|
||||
const labelCls2 = 'block text-sm font-medium text-foreground mb-2'
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-card text-foreground'
|
||||
const selectCls = 'w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-card text-foreground'
|
||||
const toggleOffBorder = 'border-border hover:border-primary/30'
|
||||
const toggleOffIcon = 'text-muted-foreground'
|
||||
const toggleOffLabel = 'text-sm font-medium text-foreground'
|
||||
const toggleOffHint = 'text-xs text-muted-foreground'
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps) {
|
||||
@@ -187,26 +197,26 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="bg-card rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header — editable agent name */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="text-lg font-semibold text-slate-800 bg-transparent border-none outline-none focus:ring-0 p-0 flex-1 placeholder:text-slate-300"
|
||||
className="text-lg font-semibold text-card-foreground bg-transparent border-none outline-none focus:ring-0 p-0 flex-1 placeholder:text-muted-foreground/40"
|
||||
placeholder={t('agents.form.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
<button onClick={onCancel} className="p-1 rounded-md hover:bg-slate-100 ml-3">
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
<button onClick={onCancel} className="p-1 rounded-md hover:bg-accent ml-3">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
{/* Agent Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('agents.form.agentType')}<FieldHelp tooltip={t('agents.help.tooltips.agentType')} /></label>
|
||||
<label className={labelCls2}>{t('agents.form.agentType')}<FieldHelp tooltip={t('agents.help.tooltips.agentType')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{agentTypes.map(at => (
|
||||
<button
|
||||
@@ -217,11 +227,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
text-left px-3 py-2.5 rounded-lg border-2 transition-all text-sm
|
||||
${type === at.value
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: 'border-slate-200 text-slate-600 hover:border-slate-300'}
|
||||
: `${toggleOffBorder} text-muted-foreground`}
|
||||
`}
|
||||
>
|
||||
<div className="font-medium">{t(at.labelKey)}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">{t(at.descKey)}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{t(at.descKey)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -230,12 +240,12 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
{/* Research Topic (researcher only) — replaces Description for this type */}
|
||||
{type === 'researcher' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.researchTopic')}<FieldHelp tooltip={t('agents.help.tooltips.researchTopic')} /></label>
|
||||
<label className={labelCls}>{t('agents.form.researchTopic')}<FieldHelp tooltip={t('agents.help.tooltips.researchTopic')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
className={inputCls}
|
||||
placeholder={t('agents.form.researchTopicPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -244,12 +254,12 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
{/* Description (for non-researcher types) */}
|
||||
{type !== 'researcher' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.description')}<FieldHelp tooltip={t('agents.help.tooltips.description')} /></label>
|
||||
<label className={labelCls}>{t('agents.form.description')}<FieldHelp tooltip={t('agents.help.tooltips.description')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
className={inputCls}
|
||||
placeholder={t('agents.form.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -258,7 +268,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
{/* URLs (scraper and custom only — researcher uses search, not URLs) */}
|
||||
{(type === 'scraper' || type === 'custom') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<label className={labelCls}>
|
||||
{t('agents.form.urlsLabel')}<FieldHelp tooltip={t('agents.help.tooltips.urls')} />
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
@@ -268,14 +278,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => updateUrl(i, e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
className={inputCls}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeUrl(i)}
|
||||
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -297,11 +307,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
{/* Source Notebook (monitor only) */}
|
||||
{showSourceNotebook && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
||||
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
||||
<select
|
||||
value={sourceNotebookId}
|
||||
onChange={e => setSourceNotebookId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="">{t('agents.form.selectNotebook')}</option>
|
||||
{notebooks.map(nb => (
|
||||
@@ -315,11 +325,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
|
||||
{/* Target Notebook */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
|
||||
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
|
||||
<select
|
||||
value={targetNotebookId}
|
||||
onChange={e => setTargetNotebookId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="">{t('agents.form.inbox')}</option>
|
||||
{notebooks.map(nb => (
|
||||
@@ -332,11 +342,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></label>
|
||||
<label className={labelCls}>{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="manual">{t('agents.frequencies.manual')}</option>
|
||||
<option value="hourly">{t('agents.frequencies.hourly')}</option>
|
||||
@@ -352,16 +362,16 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
notifyEmail
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
: toggleOffBorder
|
||||
}`}
|
||||
>
|
||||
<Mail className={`w-4 h-4 flex-shrink-0 ${notifyEmail ? 'text-primary' : 'text-slate-400'}`} />
|
||||
<Mail className={`w-4 h-4 flex-shrink-0 ${notifyEmail ? 'text-primary' : toggleOffIcon}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-700">{t('agents.form.notifyEmail')}</div>
|
||||
<div className="text-xs text-slate-400">{t('agents.form.notifyEmailHint')}</div>
|
||||
<div className={toggleOffLabel}>{t('agents.form.notifyEmail')}</div>
|
||||
<div className={toggleOffHint}>{t('agents.form.notifyEmailHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${notifyEmail ? 'bg-primary' : 'bg-slate-200'}`}>
|
||||
<div className={`w-4 h-4 bg-white rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${notifyEmail ? 'bg-primary' : 'bg-muted'}`}>
|
||||
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -371,16 +381,16 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
includeImages
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
: toggleOffBorder
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className={`w-4 h-4 flex-shrink-0 ${includeImages ? 'text-primary' : 'text-slate-400'}`} />
|
||||
<ImageIcon className={`w-4 h-4 flex-shrink-0 ${includeImages ? 'text-primary' : toggleOffIcon}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-700">{t('agents.form.includeImages')}</div>
|
||||
<div className="text-xs text-slate-400">{t('agents.form.includeImagesHint')}</div>
|
||||
<div className={toggleOffLabel}>{t('agents.form.includeImages')}</div>
|
||||
<div className={toggleOffHint}>{t('agents.form.includeImagesHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${includeImages ? 'bg-primary' : 'bg-slate-200'}`}>
|
||||
<div className={`w-4 h-4 bg-white rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${includeImages ? 'bg-primary' : 'bg-muted'}`}>
|
||||
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,7 +398,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700 font-medium w-full pt-2 border-t border-slate-100"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground font-medium w-full pt-2 border-t border-border"
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{t('agents.form.advancedMode')}
|
||||
@@ -398,23 +408,23 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{t('agents.form.instructions')}
|
||||
<FieldHelp tooltip={t('agents.help.tooltips.instructions')} />
|
||||
<span className="text-xs text-slate-400 font-normal ml-1">({t('agents.form.instructionsHint')})</span>
|
||||
<span className="text-xs text-muted-foreground font-normal ml-1">({t('agents.form.instructionsHint')})</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={role}
|
||||
onChange={e => setRole(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-y min-h-[80px]"
|
||||
className="w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-y min-h-[80px] bg-card text-foreground"
|
||||
placeholder={t('agents.form.instructionsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: Tools */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('agents.tools.title')}<FieldHelp tooltip={t('agents.help.tooltips.tools')} /></label>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">{t('agents.tools.title')}<FieldHelp tooltip={t('agents.help.tooltips.tools')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableTools.map(at => {
|
||||
const Icon = at.icon
|
||||
@@ -432,20 +442,20 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all text-left
|
||||
${isSelected
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: 'border-slate-200 text-slate-600 hover:border-slate-300'}
|
||||
: `${toggleOffBorder} text-muted-foreground`}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t(at.labelKey)}</span>
|
||||
{at.external && !isSelected && (
|
||||
<span className="ml-auto text-[10px] text-amber-500 bg-amber-50 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
|
||||
<span className="ml-auto text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedTools.length > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-1.5">
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t('agents.tools.selected', { count: selectedTools.length })}
|
||||
</p>
|
||||
)}
|
||||
@@ -454,9 +464,9 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
{/* Advanced: Max Steps */}
|
||||
{selectedTools.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t('agents.tools.maxSteps')}<FieldHelp tooltip={t('agents.help.tooltips.maxSteps')} />
|
||||
<span className="text-slate-400 font-normal ml-1">({maxSteps})</span>
|
||||
<span className="text-muted-foreground font-normal ml-1">({maxSteps})</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -466,7 +476,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
onChange={e => setMaxSteps(Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>3</span>
|
||||
<span>25</span>
|
||||
</div>
|
||||
@@ -480,14 +490,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
className="px-4 py-2 text-sm font-medium text-muted-foreground bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
{t('agents.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? t('agents.form.saving') : agent ? t('agents.form.save') : t('agents.form.create')}
|
||||
</button>
|
||||
|
||||
@@ -66,15 +66,15 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full max-h-[70vh] overflow-hidden flex flex-col">
|
||||
<div className="bg-card rounded-2xl shadow-xl max-w-md w-full max-h-[70vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800">{t('agents.runLog.title')}</h3>
|
||||
<p className="text-xs text-slate-400">{agentName}</p>
|
||||
<h3 className="font-semibold text-card-foreground">{t('agents.runLog.title')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{agentName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-md hover:bg-slate-100">
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
<button onClick={onClose} className="p-1 rounded-md hover:bg-accent">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -82,12 +82,12 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-slate-400" />
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && actions.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-400 py-8">
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
{t('agents.runLog.noHistory')}
|
||||
</p>
|
||||
)}
|
||||
@@ -103,10 +103,10 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
key={action.id}
|
||||
className={`
|
||||
p-3 rounded-lg border
|
||||
${action.status === 'success' ? 'bg-green-50/50 border-green-100' : ''}
|
||||
${action.status === 'failure' ? 'bg-red-50/50 border-red-100' : ''}
|
||||
${action.status === 'running' ? 'bg-blue-50/50 border-blue-100' : ''}
|
||||
${action.status === 'pending' ? 'bg-slate-50 border-slate-100' : ''}
|
||||
${action.status === 'success' ? 'bg-green-50/50 dark:bg-green-950/50 border-green-100 dark:border-green-900' : ''}
|
||||
${action.status === 'failure' ? 'bg-red-50/50 dark:bg-red-950/50 border-red-100 dark:border-red-900' : ''}
|
||||
${action.status === 'running' ? 'bg-blue-50/50 dark:bg-blue-950/50 border-blue-100 dark:border-blue-900' : ''}
|
||||
${action.status === 'pending' ? 'bg-muted border-border' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -114,19 +114,19 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||
{action.status === 'failure' && <XCircle className="w-4 h-4 text-red-500" />}
|
||||
{action.status === 'running' && <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />}
|
||||
{action.status === 'pending' && <Clock className="w-4 h-4 text-slate-400" />}
|
||||
{action.status === 'pending' && <Clock className="w-4 h-4 text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t(statusKeys[action.status] || action.status)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
{action.log && (
|
||||
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{action.log}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{action.log}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,13 +142,13 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
<div className="mt-2 space-y-2 pl-2">
|
||||
{toolSteps.map((step, i) => (
|
||||
<div key={i} className="text-xs border-l-2 border-primary/30 pl-2 py-1">
|
||||
<span className="font-medium text-slate-600">{t('agents.runLog.step', { num: step.step })}</span>
|
||||
<span className="font-medium text-muted-foreground">{t('agents.runLog.step', { num: step.step })}</span>
|
||||
{step.toolCalls && step.toolCalls.length > 0 && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{step.toolCalls.map((tc, j) => (
|
||||
<div key={j} className="bg-slate-100 rounded px-2 py-1">
|
||||
<div key={j} className="bg-muted rounded px-2 py-1">
|
||||
<span className="font-mono text-primary">{tc.toolName}</span>
|
||||
<span className="text-slate-400 ml-1">
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{JSON.stringify(tc.args).substring(0, 80)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -14,19 +14,22 @@ import { useLanguage } from '@/lib/i18n'
|
||||
interface ChatContainerProps {
|
||||
initialConversations: any[]
|
||||
notebooks: any[]
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatContainer({ initialConversations, notebooks }: ChatContainerProps) {
|
||||
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [conversations, setConversations] = useState(initialConversations)
|
||||
const [currentId, setCurrentId] = useState<string | null>(null)
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
|
||||
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||
|
||||
// Prevents the useEffect from loading an empty conversation
|
||||
// when we just created one via createConversation()
|
||||
const skipHistoryLoad = useRef(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const transport = useRef(new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
@@ -129,6 +132,7 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
|
||||
conversationId: convId,
|
||||
notebookId: notebookId || selectedNotebook || undefined,
|
||||
language,
|
||||
webSearch: webSearchEnabled,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -139,6 +143,7 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
setSelectedNotebook(undefined)
|
||||
setWebSearchEnabled(false)
|
||||
}
|
||||
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
@@ -158,6 +163,13 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
|
||||
? messages
|
||||
: historyMessages
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [displayMessages])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden bg-white dark:bg-[#1a1c22]">
|
||||
<ChatSidebar
|
||||
@@ -169,7 +181,7 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
|
||||
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
|
||||
</div>
|
||||
|
||||
@@ -180,6 +192,9 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
|
||||
isLoading={isLoading}
|
||||
notebooks={notebooks}
|
||||
currentNotebookId={selectedNotebook || null}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
|
||||
webSearchAvailable={webSearchAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, BookOpen, X } from 'lucide-react'
|
||||
import { Send, BookOpen, X, Globe } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -21,9 +21,12 @@ interface ChatInputProps {
|
||||
isLoading?: boolean
|
||||
notebooks: any[]
|
||||
currentNotebookId?: string | null
|
||||
webSearchEnabled?: boolean
|
||||
onToggleWebSearch?: () => void
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId }: ChatInputProps) {
|
||||
export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
||||
@@ -76,8 +79,8 @@ export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId }: C
|
||||
<div className="flex items-center justify-between px-3 pb-3 pt-1">
|
||||
{/* Context Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedNotebook || 'global'}
|
||||
<Select
|
||||
value={selectedNotebook || 'global'}
|
||||
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-slate-50 dark:hover:bg-[#252830] transition-colors">
|
||||
@@ -96,12 +99,28 @@ export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId }: C
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
{selectedNotebook && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
|
||||
{t('chat.active')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{webSearchAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleWebSearch}
|
||||
className={cn(
|
||||
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
|
||||
webSearchEnabled
|
||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
|
||||
: "bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-[#252830]"
|
||||
)}
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{webSearchEnabled && t('chat.webSearch')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X, Sparkles, ThumbsUp, ThumbsDown } from 'lucide-react'
|
||||
import { Sparkles, ThumbsUp, ThumbsDown, GitMerge, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Note } from '@/lib/types'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
@@ -14,6 +14,7 @@ interface ComparisonModalProps {
|
||||
notes: Array<Partial<Note>>
|
||||
similarity?: number
|
||||
onOpenNote?: (noteId: string) => void
|
||||
onMergeNotes?: (noteIds: string[]) => void
|
||||
}
|
||||
|
||||
export function ComparisonModal({
|
||||
@@ -21,14 +22,26 @@ export function ComparisonModal({
|
||||
onClose,
|
||||
notes,
|
||||
similarity,
|
||||
onOpenNote
|
||||
onOpenNote,
|
||||
onMergeNotes
|
||||
}: ComparisonModalProps) {
|
||||
const { t } = useLanguage()
|
||||
const [feedback, setFeedback] = useState<'thumbs_up' | 'thumbs_down' | null>(null)
|
||||
|
||||
const handleFeedback = async (type: 'thumbs_up' | 'thumbs_down') => {
|
||||
setFeedback(type)
|
||||
// TODO: Send feedback to backend
|
||||
try {
|
||||
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
|
||||
if (noteIds.length >= 2) {
|
||||
await fetch('/api/ai/echo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'feedback', noteIds, feedback: type })
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// silent — feedback is best-effort
|
||||
}
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
}, 500)
|
||||
@@ -57,10 +70,13 @@ export function ComparisonModal({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className={cn(
|
||||
"max-h-[90vh] overflow-hidden flex flex-col p-0",
|
||||
maxModalWidth
|
||||
)}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"max-h-[90vh] overflow-hidden flex flex-col p-0",
|
||||
maxModalWidth
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -68,23 +84,23 @@ export function ComparisonModal({
|
||||
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{t('memoryEcho.comparison.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
className="p-1 rounded-md text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight Section - Optional for now */}
|
||||
{/* AI Insight Section */}
|
||||
{similarityPercentage >= 80 && (
|
||||
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-950/20 border-b dark:border-zinc-700">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -137,13 +153,13 @@ export function ComparisonModal({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer - Feedback */}
|
||||
{/* Footer - Feedback + Actions */}
|
||||
<div className="px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.comparison.helpfulQuestion')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.comparison.helpfulQuestion')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={feedback === 'thumbs_up' ? 'default' : 'outline'}
|
||||
@@ -167,6 +183,21 @@ export function ComparisonModal({
|
||||
{t('memoryEcho.comparison.notHelpful')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{onMergeNotes && notes.length >= 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
onClick={() => {
|
||||
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
|
||||
onMergeNotes(noteIds)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<GitMerge className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.editorSection.mergeAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -310,7 +310,7 @@ export function ConnectionsOverlay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer - Action */}
|
||||
{/* Footer - Actions */}
|
||||
<div className="px-6 py-4 border-t dark:border-zinc-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
@@ -114,8 +114,22 @@ export function FusionModal({
|
||||
try {
|
||||
// Parse the preview into title and content
|
||||
const lines = fusionPreview.split('\n')
|
||||
const title = lines[0].replace(/^#+\s*/, '').trim()
|
||||
const content = lines.slice(1).join('\n').trim()
|
||||
let title = ''
|
||||
let content = fusionPreview.trim()
|
||||
|
||||
const firstLine = lines[0].trim()
|
||||
if (firstLine.startsWith('#')) {
|
||||
title = firstLine.replace(/^#+\s*/, '').trim()
|
||||
content = lines.slice(1).join('\n').trim()
|
||||
} else {
|
||||
// No markdown heading — use first meaningful line as title
|
||||
title = firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine
|
||||
content = lines.slice(1).join('\n').trim()
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
title = notes[0]?.title || t('memoryEcho.comparison.untitled')
|
||||
}
|
||||
|
||||
await onConfirmFusion(
|
||||
{ title, content },
|
||||
@@ -132,7 +146,7 @@ export function FusionModal({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||
<DialogContent showCloseButton={false} className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -140,19 +154,19 @@ export function FusionModal({
|
||||
<Link2 className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{t('memoryEcho.fusion.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.fusion.mergeNotes', { count: selectedNoteIds.length })}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
className="p-1 rounded-md text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@ import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight } from 'lucide-react'
|
||||
import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight, GitMerge } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ComparisonModal } from './comparison-modal'
|
||||
import { FusionModal } from './fusion-modal'
|
||||
import { createNote, updateNote } from '@/app/actions/notes'
|
||||
import { Note } from '@/lib/types'
|
||||
|
||||
interface MemoryEchoInsight {
|
||||
id: string
|
||||
@@ -38,7 +42,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showComparison, setShowComparison] = useState(false)
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [demoMode, setDemoMode] = useState(false)
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
@@ -58,8 +63,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
|
||||
if (data.insight) {
|
||||
setInsight(data.insight)
|
||||
// Check if user is in demo mode by looking at frequency settings
|
||||
setDemoMode(true) // If we got an insight after dismiss, assume demo mode
|
||||
// If we got an insight, check if user is in demo mode
|
||||
setDemoMode(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to fetch insight:', error)
|
||||
@@ -70,7 +75,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
|
||||
// Start polling in demo mode after first dismiss
|
||||
useEffect(() => {
|
||||
if (isDismissed && !pollingRef.current) {
|
||||
if (isDismissed && demoMode && !pollingRef.current) {
|
||||
pollingRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/ai/echo')
|
||||
@@ -90,25 +95,20 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
pollingRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isDismissed])
|
||||
}, [isDismissed, demoMode])
|
||||
|
||||
const handleView = async () => {
|
||||
if (!insight) return
|
||||
|
||||
try {
|
||||
// Mark as viewed
|
||||
await fetch('/api/ai/echo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'view',
|
||||
insightId: insight.id
|
||||
})
|
||||
body: JSON.stringify({ action: 'view', insightId: insight.id })
|
||||
})
|
||||
|
||||
// Show success message and open modal
|
||||
toast.success(t('toast.openingConnection'))
|
||||
setShowModal(true)
|
||||
setShowComparison(true)
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to view connection:', error)
|
||||
toast.error(t('toast.openConnectionFailed'))
|
||||
@@ -122,21 +122,15 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
await fetch('/api/ai/echo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'feedback',
|
||||
insightId: insight.id,
|
||||
feedback
|
||||
})
|
||||
body: JSON.stringify({ action: 'feedback', insightId: insight.id, feedback })
|
||||
})
|
||||
|
||||
// Show feedback toast
|
||||
if (feedback === 'thumbs_up') {
|
||||
toast.success(t('toast.thanksFeedback'))
|
||||
} else {
|
||||
toast.success(t('toast.thanksFeedbackImproving'))
|
||||
}
|
||||
|
||||
// Dismiss notification
|
||||
setIsDismissed(true)
|
||||
// Stop polling after explicit feedback
|
||||
if (pollingRef.current) {
|
||||
@@ -149,225 +143,195 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
}
|
||||
}
|
||||
|
||||
const handleMergeNotes = async (noteIds: string[]) => {
|
||||
if (!insight) return
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true)
|
||||
}
|
||||
|
||||
// Don't render notification if dismissed, loading, or no insight
|
||||
if (isDismissed || isLoading || !insight) {
|
||||
if (isLoading || !insight) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate values for both notification and modal
|
||||
const note1Title = insight.note1.title || t('notification.untitled')
|
||||
const note2Title = insight.note2.title || t('notification.untitled')
|
||||
const similarityPercentage = Math.round(insight.similarityScore * 100)
|
||||
|
||||
// Render modal if requested
|
||||
if (showModal && insight) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('memoryEcho.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('connection.similarityInfo', { similarity: similarityPercentage })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false)
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI-generated insight */}
|
||||
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-950/20 border-b dark:border-zinc-700">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{insight.insight}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes Grid */}
|
||||
<div className="grid grid-cols-2 gap-6 p-6">
|
||||
{/* Note 1 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (onOpenNote) {
|
||||
onOpenNote(insight.note1.id)
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer border dark:border-zinc-700 rounded-lg p-4 hover:border-amber-300 dark:hover:border-amber-700 transition-colors"
|
||||
>
|
||||
<h3 className="font-semibold text-primary dark:text-primary-foreground mb-2">
|
||||
{note1Title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
|
||||
{insight.note1.content}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
|
||||
</div>
|
||||
|
||||
{/* Note 2 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (onOpenNote) {
|
||||
onOpenNote(insight.note2.id)
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer border dark:border-zinc-700 rounded-lg p-4 hover:border-purple-300 dark:hover:border-purple-700 transition-colors"
|
||||
>
|
||||
<h3 className="font-semibold text-purple-600 dark:text-purple-400 mb-2">
|
||||
{note2Title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
|
||||
{insight.note2.content}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Section */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('connection.isHelpful')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_up'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'hover:bg-green-50 text-green-600 dark:hover:bg-green-950/20'
|
||||
}`}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
{t('memoryEcho.helpful')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_down'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'hover:bg-red-50 text-red-600 dark:hover:bg-red-950/20'
|
||||
}`}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
{t('memoryEcho.notHelpful')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const comparisonNotes: Array<Partial<Note>> = [
|
||||
{ id: insight.note1.id, title: insight.note1.title, content: insight.note1.content },
|
||||
{ id: insight.note2.id, title: insight.note2.title, content: insight.note2.content }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
|
||||
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
<>
|
||||
{/* Comparison Modal */}
|
||||
<ComparisonModal
|
||||
isOpen={showComparison}
|
||||
onClose={() => {
|
||||
setShowComparison(false)
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
notes={comparisonNotes}
|
||||
similarity={insight.similarityScore}
|
||||
onOpenNote={(noteId) => {
|
||||
setShowComparison(false)
|
||||
setIsDismissed(true)
|
||||
onOpenNote?.(noteId)
|
||||
}}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
/>
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes.length > 0 && (
|
||||
<FusionModal
|
||||
isOpen={fusionNotes.length > 0}
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={async ({ title, content }, options) => {
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
|
||||
: fusionNotes[0].labels || [],
|
||||
color: fusionNotes[0].color,
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notification Card — hidden when dismissed or modal open */}
|
||||
{!isDismissed && (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
|
||||
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{t('memoryEcho.title')}
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('memoryEcho.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -mr-2 -mt-2"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{t('memoryEcho.title')}
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('memoryEcho.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* AI-generated insight */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{insight.insight}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -mr-2 -mt-2"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* AI-generated insight */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{insight.insight}
|
||||
</p>
|
||||
</div>
|
||||
{/* Connected notes */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
|
||||
{note1Title}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-gray-400" />
|
||||
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
|
||||
{note2Title}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{t('memoryEcho.match', { percentage: similarityPercentage })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected notes */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
|
||||
{note1Title}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-gray-400" />
|
||||
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
|
||||
{note2Title}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{t('memoryEcho.match', { percentage: similarityPercentage })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={handleView}
|
||||
>
|
||||
{t('memoryEcho.viewConnection')}
|
||||
</Button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={handleView}
|
||||
>
|
||||
{t('memoryEcho.viewConnection')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/20"
|
||||
onClick={() => handleMergeNotes([insight.note1.id, insight.note2.id])}
|
||||
>
|
||||
<GitMerge className="h-4 w-4 mr-1" />
|
||||
{t('memoryEcho.editorSection.merge')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 border-l pl-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
title={t('memoryEcho.helpful')}
|
||||
<div className="flex items-center gap-1 border-l pl-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
title={t('memoryEcho.helpful')}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
title={t('memoryEcho.notHelpful')}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss link */}
|
||||
<button
|
||||
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
title={t('memoryEcho.notHelpful')}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss link */}
|
||||
<button
|
||||
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
{t('memoryEcho.dismiss')}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{t('memoryEcho.dismiss')}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LogOut, Trash2 } from 'lucide-react'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, restoreNote, permanentDeleteNote, createNote } from '@/app/actions/notes'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -48,7 +48,6 @@ import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
import { NoteActions } from './note-actions'
|
||||
import { CollaboratorDialog } from './collaborator-dialog'
|
||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode'
|
||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
||||
import { ConnectionsBadge } from './connections-badge'
|
||||
import { ConnectionsOverlay } from './connections-overlay'
|
||||
@@ -60,7 +59,6 @@ import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { toast } from 'sonner'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
// Mapping of supported languages to date-fns locales
|
||||
const localeMap: Record<string, Locale> = {
|
||||
@@ -85,6 +83,28 @@ function getDateLocale(language: string): Locale {
|
||||
return localeMap[language] || enUS
|
||||
}
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
function getNotebookIcon(iconName: string): LucideIcon {
|
||||
const IconComponent = ICON_MAP[iconName] || Folder
|
||||
return IconComponent
|
||||
}
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
@@ -145,7 +165,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
const [, startTransition] = useTransition()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showPermanentDeleteDialog, setShowPermanentDeleteDialog] = useState(false)
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
||||
const [owner, setOwner] = useState<any>(null)
|
||||
@@ -185,10 +204,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
|
||||
const isOwner = currentUserId && note.userId && currentUserId === note.userId
|
||||
|
||||
// Card size mode from settings
|
||||
const cardSizeMode = useCardSizeMode()
|
||||
const isUniformMode = cardSizeMode === 'uniform'
|
||||
|
||||
// Load collaborators only for shared notes (not owned by current user)
|
||||
useEffect(() => {
|
||||
// Skip API call for notes owned by current user — no need to fetch collaborators
|
||||
@@ -272,16 +287,26 @@ export const NoteCard = memo(function NoteCard({
|
||||
})
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: 'small' | 'medium' | 'large') => {
|
||||
// Notifier le parent immédiatement (hors transition) — c'est lui
|
||||
// qui détient la source de vérité via localNotes
|
||||
onSizeChange?.(size)
|
||||
onResize?.()
|
||||
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
|
||||
startTransition(async () => {
|
||||
// Instant visual feedback for the card itself
|
||||
addOptimisticNote({ size })
|
||||
|
||||
// Persister en arrière-plan
|
||||
updateSize(note.id, size).catch(err =>
|
||||
console.error('Failed to update note size:', err)
|
||||
)
|
||||
// Notify parent so it can update its local state
|
||||
onSizeChange?.(size)
|
||||
|
||||
// Trigger layout refresh
|
||||
onResize?.()
|
||||
setTimeout(() => onResize?.(), 300)
|
||||
|
||||
// Update server in background
|
||||
|
||||
try {
|
||||
await updateSize(note.id, size);
|
||||
} catch (error) {
|
||||
console.error('Failed to update note size:', error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCheckItem = async (checkItemId: string) => {
|
||||
@@ -308,27 +333,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
await restoreNote(note.id)
|
||||
setIsDeleting(true) // Hide the note from trash view
|
||||
toast.success(t('trash.noteRestored'))
|
||||
} catch (error) {
|
||||
console.error('Failed to restore note:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermanentDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await permanentDeleteNote(note.id)
|
||||
toast.success(t('trash.notePermanentlyDeleted'))
|
||||
} catch (error) {
|
||||
console.error('Failed to permanently delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent opening the note editor
|
||||
startTransition(async () => {
|
||||
@@ -355,11 +359,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
data-testid="note-card"
|
||||
data-draggable="true"
|
||||
data-note-id={note.id}
|
||||
data-size={isUniformMode ? 'small' : optimisticNote.size}
|
||||
style={{ minHeight: isUniformMode ? 'auto' : getMinHeight(optimisticNote.size) }}
|
||||
draggable={!isTrashView}
|
||||
data-size={optimisticNote.size}
|
||||
style={{ minHeight: getMinHeight(optimisticNote.size) }}
|
||||
draggable={true}
|
||||
onDragStart={(e) => {
|
||||
if (isTrashView) return
|
||||
e.dataTransfer.setData('text/plain', note.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
|
||||
@@ -385,8 +388,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag Handle - Only visible on mobile/touch devices, not in trash */}
|
||||
{!isTrashView && (
|
||||
{/* Drag Handle - Only visible on mobile/touch devices */}
|
||||
<div
|
||||
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
||||
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
|
||||
@@ -394,10 +396,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Move to Notebook Dropdown Menu - Hidden in trash */}
|
||||
{!isTrashView && (
|
||||
{/* Move to Notebook Dropdown Menu */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -433,10 +433,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pin Button - Visible on hover or if pinned, hidden in trash */}
|
||||
{!isTrashView && (
|
||||
{/* Pin Button - Visible on hover or if pinned */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -454,7 +452,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@@ -465,35 +462,20 @@ export const NoteCard = memo(function NoteCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory Echo Badges - Fusion + Connections (BEFORE Title) */}
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{/* Fusion Badge with remove button */}
|
||||
{note.autoGenerated && (
|
||||
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative">
|
||||
<Link2 className="h-2.5 w-2.5" />
|
||||
{t('memoryEcho.fused')}
|
||||
<button
|
||||
onClick={handleRemoveFusedBadge}
|
||||
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
||||
title={t('notes.remove') || 'Remove'}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections Badge */}
|
||||
<ConnectionsBadge
|
||||
noteId={note.id}
|
||||
onClick={() => {
|
||||
// Only open overlay if note is NOT open in editor
|
||||
// (to avoid having 2 Dialogs with 2 close buttons)
|
||||
if (!isNoteOpenInEditor) {
|
||||
setShowConnectionsOverlay(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Fusion Badge */}
|
||||
{optimisticNote.aiProvider === 'fusion' && (
|
||||
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative mb-2 w-fit">
|
||||
<Link2 className="h-2.5 w-2.5" />
|
||||
{t('memoryEcho.fused')}
|
||||
<button
|
||||
onClick={handleRemoveFusedBadge}
|
||||
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
||||
title={t('notes.remove') || 'Remove'}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{optimisticNote.title && (
|
||||
@@ -611,22 +593,19 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar Component - hide destructive actions for shared notes */}
|
||||
{!isSharedNote && (
|
||||
{/* Action Bar Component - Always show for now to fix regression */}
|
||||
{true && (
|
||||
<NoteActions
|
||||
isPinned={optimisticNote.isPinned}
|
||||
isArchived={optimisticNote.isArchived}
|
||||
currentColor={optimisticNote.color}
|
||||
currentSize={isUniformMode ? 'small' : (optimisticNote.size as 'small' | 'medium' | 'large')}
|
||||
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
onColorChange={handleColorChange}
|
||||
onSizeChange={isUniformMode ? undefined : handleSizeChange}
|
||||
onSizeChange={handleSizeChange}
|
||||
onDelete={() => setShowDeleteDialog(true)}
|
||||
onShareCollaborators={() => setShowCollaboratorDialog(true)}
|
||||
isTrashView={isTrashView}
|
||||
onRestore={handleRestore}
|
||||
onPermanentDelete={() => setShowPermanentDeleteDialog(true)}
|
||||
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
@@ -644,6 +623,18 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections Badge - Bottom right (spec: amber, absolute) */}
|
||||
<div className="absolute bottom-2 right-2 z-10">
|
||||
<ConnectionsBadge
|
||||
noteId={note.id}
|
||||
onClick={() => {
|
||||
if (!isNoteOpenInEditor) {
|
||||
setShowConnectionsOverlay(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Connections Overlay */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ConnectionsOverlay
|
||||
@@ -651,6 +642,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
onClose={() => setShowConnectionsOverlay(false)}
|
||||
noteId={note.id}
|
||||
onOpenNote={(connNoteId) => {
|
||||
setShowConnectionsOverlay(false)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('note', connNoteId)
|
||||
router.push(`?${params.toString()}`)
|
||||
@@ -690,10 +682,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
)}
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes && fusionNotes.length > 0 && (
|
||||
{fusionNotes.length > 0 && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FusionModal
|
||||
isOpen={!!fusionNotes}
|
||||
isOpen={fusionNotes.length > 0}
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={async ({ title, content }, options) => {
|
||||
@@ -707,6 +699,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
@@ -715,6 +708,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
triggerRefresh()
|
||||
}}
|
||||
/>
|
||||
@@ -738,24 +732,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Permanent Delete Confirmation Dialog (Trash view only) */}
|
||||
<AlertDialog open={showPermanentDeleteDialog} onOpenChange={setShowPermanentDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('trash.permanentDelete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('trash.permanentDeleteConfirm')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handlePermanentDelete}>
|
||||
{t('trash.permanentDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
@@ -1150,6 +1150,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
type: 'text',
|
||||
isMarkdown: true, // AI generates markdown content
|
||||
autoGenerated: true, // Mark as AI-generated fused note
|
||||
aiProvider: 'fusion',
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined // Keep the notebook from the first note, convert null to undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ import {
|
||||
toggleArchive,
|
||||
updateColor,
|
||||
deleteNote,
|
||||
removeImageFromNote,
|
||||
leaveSharedNote,
|
||||
createNote,
|
||||
} from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
@@ -55,8 +53,6 @@ import {
|
||||
RotateCcw,
|
||||
Languages,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
@@ -108,12 +104,10 @@ export function NoteInlineEditor({
|
||||
defaultPreviewMode = false,
|
||||
}: NoteInlineEditorProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
|
||||
const { labels: globalLabels, addLabel } = useLabels()
|
||||
const [, startTransition] = useTransition()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const isSharedNote = !!(note as any)._isShared
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const [content, setContent] = useState(note.content || '')
|
||||
@@ -132,34 +126,6 @@ export function NoteInlineEditor({
|
||||
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
|
||||
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
|
||||
|
||||
// Textarea ref for formatting toolbar
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const applyFormat = (prefix: string, suffix: string = prefix) => {
|
||||
const textarea = textAreaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = content.substring(start, end)
|
||||
const before = content.substring(0, start)
|
||||
const after = content.substring(end)
|
||||
|
||||
const newContent = before + prefix + selected + suffix + after
|
||||
changeContent(newContent)
|
||||
scheduleSave()
|
||||
|
||||
// Restore cursor position after React re-renders
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
const newCursorPos = selected ? end + prefix.length + suffix.length : start + prefix.length
|
||||
textarea.setSelectionRange(
|
||||
selected ? start + prefix.length : start + prefix.length,
|
||||
selected ? end + prefix.length : newCursorPos
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Link dialog
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||
@@ -272,78 +238,30 @@ export function NoteInlineEditor({
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try {
|
||||
await addLabel(tag)
|
||||
// Refresh labels to get the new color assignment
|
||||
await refreshLabels()
|
||||
} catch {}
|
||||
try { await addLabel(tag) } catch {}
|
||||
}
|
||||
toast.success(t('ai.tagAdded', { tag }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLabel = async (label: string) => {
|
||||
const newLabels = (note.labels || []).filter((l) => l !== label)
|
||||
// Optimistic UI
|
||||
onChange?.(note.id, { labels: newLabels })
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
toast.success(t('labels.labelRemoved', { label }))
|
||||
}
|
||||
|
||||
// ── Shared note actions ────────────────────────────────────────────────────
|
||||
const handleMakeCopy = async () => {
|
||||
try {
|
||||
await createNote({
|
||||
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
||||
content,
|
||||
color: note.color,
|
||||
type: note.type,
|
||||
checkItems: note.checkItems ?? undefined,
|
||||
labels: note.labels ?? undefined,
|
||||
images: note.images ?? undefined,
|
||||
links: note.links ?? undefined,
|
||||
isMarkdown,
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
triggerRefresh()
|
||||
} catch (error) {
|
||||
toast.error(t('notes.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
triggerRefresh()
|
||||
onDelete?.(note.id)
|
||||
} catch (error) {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
const fetchNotesByIds = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
return fetched.filter((n: any) => n !== null) as Array<Partial<Note>>
|
||||
}
|
||||
|
||||
const handleMergeNotes = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
setFusionNotes(await fetchNotesByIds(noteIds))
|
||||
}
|
||||
|
||||
const handleCompareNotes = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setComparisonNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
setComparisonNotes(await fetchNotesByIds(noteIds))
|
||||
}
|
||||
|
||||
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
|
||||
@@ -357,11 +275,12 @@ export function NoteInlineEditor({
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
|
||||
if (n.id) await updateNote(n.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
@@ -395,21 +314,10 @@ export function NoteInlineEditor({
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
toast(t('notes.confirmDelete'), {
|
||||
action: {
|
||||
label: t('notes.delete'),
|
||||
onClick: () => {
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
})
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: t('common.cancel'),
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 5000,
|
||||
if (!confirm(t('notes.confirmDelete'))) return
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -437,7 +345,7 @@ export function NoteInlineEditor({
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await removeImageFromNote(note.id, index)
|
||||
await updateNote(note.id, { images: newImages })
|
||||
}
|
||||
|
||||
// ── Link ──────────────────────────────────────────────────────────────────
|
||||
@@ -581,27 +489,7 @@ export function NoteInlineEditor({
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
|
||||
{/* ── Shared note banner ──────────────────────────────────────────── */}
|
||||
{isSharedNote && (
|
||||
<div className="flex items-center justify-between border-b border-border/30 bg-primary/5 dark:bg-primary/10 px-4 py-2">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{t('notes.sharedReadOnly') || 'Lecture seule — note partagée'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="default" size="sm" className="h-7 gap-1.5 text-xs" onClick={handleMakeCopy}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t('notes.makeCopy') || 'Copier'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30" onClick={handleLeaveShare}>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{t('notes.leaveShare') || 'Quitter'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Toolbar (hidden for shared notes) ────────────────────────────── */}
|
||||
{!isSharedNote && (
|
||||
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Image upload */}
|
||||
@@ -789,6 +677,12 @@ export function NoteInlineEditor({
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Pin */}
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
title={note.isPinned ? t('notes.unpin') : t('notes.pin')} onClick={handleTogglePin}>
|
||||
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current text-primary')} />
|
||||
</Button>
|
||||
|
||||
{/* Color picker */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -835,7 +729,6 @@ export function NoteInlineEditor({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
|
||||
{showLinkInput && (
|
||||
@@ -863,11 +756,7 @@ export function NoteInlineEditor({
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
|
||||
{/* Existing labels */}
|
||||
{(note.labels ?? []).map((label) => (
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => handleRemoveLabel(label)}
|
||||
/>
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
{/* AI-suggested tags inline with labels */}
|
||||
<GhostTags
|
||||
@@ -891,7 +780,6 @@ export function NoteInlineEditor({
|
||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||
value={title}
|
||||
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
/>
|
||||
{/* AI title suggestion — show when title is empty and there's content */}
|
||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||
@@ -976,21 +864,17 @@ export function NoteInlineEditor({
|
||||
<MarkdownContent content={content || ''} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
|
||||
: t('notes.takeNote') || 'Écris quelque chose…'
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
</>
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
|
||||
: t('notes.takeNote') || 'Écris quelque chose…'
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ghost tag suggestions are now shown in the top labels strip */}
|
||||
@@ -1049,17 +933,18 @@ export function NoteInlineEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section (not for shared notes) ── */}
|
||||
{!isSharedNote && (
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section ── */}
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onOpenNote={(connNoteId) => {
|
||||
window.open(`/?note=${connNoteId}`, '_blank')
|
||||
}}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
/>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
||||
<div className="shrink-0 border-t border-border/20 px-8 py-2">
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
|
||||
|
||||
@@ -259,7 +259,7 @@ async function executeScraperAgent(
|
||||
const r = articleResults[i]
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
const article = articlesToScrape[i]
|
||||
const dateStr = article.pubDate ? ` — ${new Date(article.pubDate).toLocaleDateString(dateLocale)}` : ''
|
||||
const dateStr = article.pubDate ? ` — ${new Date(article.pubDate).toISOString().split('T')[0]}` : ''
|
||||
scrapedParts.push(`## ${article.title}\n_Source: ${article.link}_${dateStr}\n\n${r.value.substring(0, 3000)}`)
|
||||
sourceLinks.push(article.link)
|
||||
articleCount++
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAIProvider } from '../factory'
|
||||
import { cosineSimilarity } from '@/lib/utils'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export interface NoteConnection {
|
||||
@@ -52,10 +53,53 @@ export class MemoryEchoService {
|
||||
private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode
|
||||
private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam
|
||||
|
||||
/**
|
||||
* Generate embeddings for notes that don't have one yet
|
||||
*/
|
||||
private async ensureEmbeddings(userId: string): Promise<void> {
|
||||
const notesWithoutEmbeddings = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
noteEmbedding: { is: null }
|
||||
},
|
||||
select: { id: true, content: true }
|
||||
})
|
||||
|
||||
if (notesWithoutEmbeddings.length === 0) return
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
for (const note of notesWithoutEmbeddings) {
|
||||
if (!note.content || note.content.trim().length === 0) continue
|
||||
try {
|
||||
const embedding = await provider.getEmbeddings(note.content)
|
||||
if (embedding && embedding.length > 0) {
|
||||
await prisma.noteEmbedding.upsert({
|
||||
where: { noteId: note.id },
|
||||
create: { noteId: note.id, embedding: JSON.stringify(embedding) },
|
||||
update: { embedding: JSON.stringify(embedding) }
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Skip this note, continue with others
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Provider not configured — nothing we can do
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find meaningful connections between user's notes
|
||||
*/
|
||||
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
|
||||
// Ensure all notes have embeddings before searching for connections
|
||||
await this.ensureEmbeddings(userId)
|
||||
|
||||
// Get all user's notes with embeddings
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
@@ -92,6 +136,23 @@ export class MemoryEchoService {
|
||||
const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART
|
||||
const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD
|
||||
|
||||
// Load user feedback to adjust thresholds per note
|
||||
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
|
||||
where: { userId, feedback: { not: null } },
|
||||
select: { note1Id: true, note2Id: true, feedback: true }
|
||||
})
|
||||
|
||||
const notePenalty = new Map<string, number>() // positive = higher threshold (penalty), negative = lower (boost)
|
||||
for (const fi of feedbackInsights) {
|
||||
if (fi.feedback === 'thumbs_down') {
|
||||
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) + 0.15)
|
||||
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) + 0.15)
|
||||
} else if (fi.feedback === 'thumbs_up') {
|
||||
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) - 0.05)
|
||||
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) - 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare all pairs of notes
|
||||
for (let i = 0; i < notesWithEmbeddings.length; i++) {
|
||||
for (let j = i + 1; j < notesWithEmbeddings.length; j++) {
|
||||
@@ -111,8 +172,11 @@ export class MemoryEchoService {
|
||||
// Calculate cosine similarity
|
||||
const similarity = cosineSimilarity(note1.embedding!, note2.embedding!)
|
||||
|
||||
// Similarity threshold for meaningful connections
|
||||
if (similarity >= similarityThreshold) {
|
||||
// Similarity threshold for meaningful connections (adjusted by feedback)
|
||||
const adjustedThreshold = similarityThreshold
|
||||
+ (notePenalty.get(note1.id) || 0)
|
||||
+ (notePenalty.get(note2.id) || 0)
|
||||
if (similarity >= adjustedThreshold) {
|
||||
connections.push({
|
||||
note1: {
|
||||
id: note1.id,
|
||||
@@ -151,8 +215,8 @@ export class MemoryEchoService {
|
||||
note2Content: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const config = await prisma.systemConfig.findFirst()
|
||||
const provider = getAIProvider(config || undefined)
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
const note1Desc = note1Title || 'Untitled note'
|
||||
const note2Desc = note2Title || 'Untitled note'
|
||||
@@ -366,6 +430,9 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
* Get all connections for a specific note
|
||||
*/
|
||||
async getConnectionsForNote(noteId: string, userId: string): Promise<NoteConnection[]> {
|
||||
// Ensure all notes have embeddings before searching
|
||||
await this.ensureEmbeddings(userId)
|
||||
|
||||
// Get the note with embedding
|
||||
const targetNote = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
@@ -446,6 +513,22 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART
|
||||
const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD
|
||||
|
||||
// Load user feedback to adjust thresholds
|
||||
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
|
||||
where: { userId, feedback: { not: null } },
|
||||
select: { note1Id: true, note2Id: true, feedback: true }
|
||||
})
|
||||
const notePenalty = new Map<string, number>()
|
||||
for (const fi of feedbackInsights) {
|
||||
if (fi.feedback === 'thumbs_down') {
|
||||
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) + 0.15)
|
||||
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) + 0.15)
|
||||
} else if (fi.feedback === 'thumbs_up') {
|
||||
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) - 0.05)
|
||||
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) - 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
const connections: NoteConnection[] = []
|
||||
|
||||
// Compare target note with all other notes
|
||||
@@ -475,8 +558,11 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
// Calculate cosine similarity
|
||||
const similarity = cosineSimilarity(targetEmbedding, otherEmbedding)
|
||||
|
||||
// Similarity threshold
|
||||
if (similarity >= similarityThreshold) {
|
||||
// Similarity threshold (adjusted by feedback)
|
||||
const adjustedThreshold = similarityThreshold
|
||||
+ (notePenalty.get(targetNote.id) || 0)
|
||||
+ (notePenalty.get(otherNote.id) || 0)
|
||||
if (similarity >= adjustedThreshold) {
|
||||
connections.push({
|
||||
note1: {
|
||||
id: targetNote.id,
|
||||
|
||||
@@ -14,12 +14,13 @@ toolRegistry.register({
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: 'Search the user\'s notes by keyword or semantic meaning. Returns matching notes with titles and content excerpts.',
|
||||
description: 'Search the user\'s notes by keyword or semantic meaning. Returns matching notes with titles and content excerpts. Optionally restrict to a specific notebook.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
limit: z.number().optional().describe('Max results to return (default 5)').default(5),
|
||||
notebookId: z.string().optional().describe('Optional notebook ID to restrict search to a specific notebook'),
|
||||
}),
|
||||
execute: async ({ query, limit = 5 }) => {
|
||||
execute: async ({ query, limit = 5, notebookId }) => {
|
||||
try {
|
||||
// Keyword fallback search using Prisma
|
||||
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)
|
||||
@@ -31,6 +32,7 @@ toolRegistry.register({
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: ctx.userId,
|
||||
...(notebookId ? { notebookId } : {}),
|
||||
...(conditions.length > 0 ? { OR: conditions } : {}),
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
|
||||
@@ -9,8 +9,11 @@ import { z } from 'zod'
|
||||
|
||||
export interface ToolContext {
|
||||
userId: string
|
||||
agentId: string
|
||||
actionId: string
|
||||
agentId?: string
|
||||
actionId?: string
|
||||
conversationId?: string
|
||||
notebookId?: string
|
||||
webSearch?: boolean
|
||||
config: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -43,6 +46,29 @@ class ToolRegistry {
|
||||
return built
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tools for the chat endpoint.
|
||||
* Includes internal tools (note_search, note_read) and web tools when configured.
|
||||
*/
|
||||
buildToolsForChat(ctx: ToolContext): Record<string, any> {
|
||||
const toolNames: string[] = ['note_search', 'note_read']
|
||||
|
||||
// Add web tools only when user toggled web search AND config is present
|
||||
if (ctx.webSearch) {
|
||||
const hasWebSearch = ctx.config.WEB_SEARCH_PROVIDER || ctx.config.BRAVE_SEARCH_API_KEY || ctx.config.SEARXNG_URL
|
||||
if (hasWebSearch) {
|
||||
toolNames.push('web_search')
|
||||
}
|
||||
|
||||
const hasWebScrape = ctx.config.JINA_API_KEY
|
||||
if (hasWebScrape) {
|
||||
toolNames.push('web_scrape')
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildToolsForAgent(toolNames, ctx)
|
||||
}
|
||||
|
||||
getAvailableTools(): Array<{ name: string; description: string; isInternal: boolean }> {
|
||||
return Array.from(this.tools.values()).map(t => ({
|
||||
name: t.name,
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface Note {
|
||||
notebookId?: string | null;
|
||||
notebook?: Notebook | null;
|
||||
autoGenerated?: boolean | null;
|
||||
aiProvider?: string | null;
|
||||
// Search result metadata (optional)
|
||||
matchType?: 'exact' | 'related' | null;
|
||||
searchScore?: number | null;
|
||||
|
||||
@@ -77,13 +77,14 @@ export function getHashColor(name: string): LabelColorName {
|
||||
}
|
||||
|
||||
export function cosineSimilarity(vecA: number[], vecB: number[]): number {
|
||||
if (vecA.length !== vecB.length) return 0;
|
||||
if (!vecA.length || !vecB.length) return 0;
|
||||
|
||||
const minLen = Math.min(vecA.length, vecB.length);
|
||||
let dotProduct = 0;
|
||||
let mA = 0;
|
||||
let mB = 0;
|
||||
|
||||
for (let i = 0; i < vecA.length; i++) {
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
dotProduct += vecA[i] * vecB[i];
|
||||
mA += vecA[i] * vecA[i];
|
||||
mB += vecB[i] * vecB[i];
|
||||
|
||||
@@ -1360,7 +1360,8 @@
|
||||
"renameError": "خطأ في إعادة التسمية",
|
||||
"welcome": "أنا هنا لمساعدتك في تلخيص ملاحظاتك أو إنشاء أفكار جديدة أو مناقشة دفاتر ملاحظاتك.",
|
||||
"searching": "جاري البحث...",
|
||||
"noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة."
|
||||
"noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة.",
|
||||
"webSearch": "بحث الويب"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "المختبر",
|
||||
|
||||
@@ -1383,7 +1383,8 @@
|
||||
"renameError": "Fehler beim Umbenennen",
|
||||
"welcome": "Ich bin hier, um Ihnen zu helfen, Ihre Notizen zusammenzufassen, neue Ideen zu generieren oder Ihre Notizbücher zu besprechen.",
|
||||
"searching": "Wird gesucht...",
|
||||
"noNotesFoundForContext": "Keine relevanten Notizen für diese Frage gefunden. Antworte mit deinem Allgemeinwissen."
|
||||
"noNotesFoundForContext": "Keine relevanten Notizen für diese Frage gefunden. Antworte mit deinem Allgemeinwissen.",
|
||||
"webSearch": "Websuche"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "Das Labor",
|
||||
|
||||
@@ -1392,7 +1392,8 @@
|
||||
"renameError": "Error renaming",
|
||||
"welcome": "I'm here to help you synthesize your notes, generate new ideas, or discuss your notebooks.",
|
||||
"searching": "Searching...",
|
||||
"noNotesFoundForContext": "No relevant notes found for this question. Answer with your general knowledge."
|
||||
"noNotesFoundForContext": "No relevant notes found for this question. Answer with your general knowledge.",
|
||||
"webSearch": "Web Search"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "The Lab",
|
||||
|
||||
@@ -1355,7 +1355,8 @@
|
||||
"renameError": "Error al renombrar",
|
||||
"welcome": "Estoy aquí para ayudarte a sintetizar tus notas, generar nuevas ideas o discutir tus cuadernos.",
|
||||
"searching": "Buscando...",
|
||||
"noNotesFoundForContext": "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general."
|
||||
"noNotesFoundForContext": "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
|
||||
"webSearch": "Búsqueda web"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "El Laboratorio",
|
||||
|
||||
@@ -1414,7 +1414,8 @@
|
||||
"renameError": "خطا در تغییر نام",
|
||||
"welcome": "من اینجا هستم تا در خلاصهسازی یادداشتها، تولید ایدههای جدید یا بحث درباره دفترچههایتان به شما کمک کنم.",
|
||||
"searching": "در حال جستجو...",
|
||||
"noNotesFoundForContext": "هیچ یادداشت مرتبطی برای این سوال یافت نشد. با دانش عمومی خود پاسخ دهید."
|
||||
"noNotesFoundForContext": "هیچ یادداشت مرتبطی برای این سوال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||||
"webSearch": "جستجوی وب"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "آزمایشگاه",
|
||||
|
||||
@@ -1388,7 +1388,8 @@
|
||||
"renameError": "Erreur lors du renommage",
|
||||
"welcome": "Je suis à votre écoute pour synthétiser vos notes, générer de nouvelles idées ou discuter de vos carnets.",
|
||||
"searching": "Recherche en cours...",
|
||||
"noNotesFoundForContext": "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales."
|
||||
"noNotesFoundForContext": "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||
"webSearch": "Recherche web"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "Le Lab",
|
||||
|
||||
@@ -1360,7 +1360,8 @@
|
||||
"renameError": "नाम बदलने में त्रुटि",
|
||||
"welcome": "मैं आपके नोट्स को संश्लेषित करने, नए विचार उत्पन्न करने या आपके नोटबुक पर चर्चा करने में मदद करने के लिए यहां हूं।",
|
||||
"searching": "खोज रहे हैं...",
|
||||
"noNotesFoundForContext": "इस प्रश्न के लिए कोई प्रासंगिक नोट्स नहीं मिले। अपने सामान्य ज्ञान से उत्तर दें।"
|
||||
"noNotesFoundForContext": "इस प्रश्न के लिए कोई प्रासंगिक नोट्स नहीं मिले। अपने सामान्य ज्ञान से उत्तर दें।",
|
||||
"webSearch": "वेब खोज"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "लैब",
|
||||
|
||||
@@ -1405,7 +1405,8 @@
|
||||
"renameError": "Errore durante la ridenominazione",
|
||||
"welcome": "Sono qui per aiutarti a sintetizzare le tue note, generare nuove idee o discutere i tuoi quaderni.",
|
||||
"searching": "Ricerca in corso...",
|
||||
"noNotesFoundForContext": "Nessuna nota rilevante trovata per questa domanda. Rispondi con la tua conoscenza generale."
|
||||
"noNotesFoundForContext": "Nessuna nota rilevante trovata per questa domanda. Rispondi con la tua conoscenza generale.",
|
||||
"webSearch": "Ricerca web"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "Il Laboratorio",
|
||||
|
||||
@@ -1383,7 +1383,8 @@
|
||||
"renameError": "名前変更エラー",
|
||||
"welcome": "ノートの要約、新しいアイデアの生成、ノートブックについてのディスカッションなどをお手伝いします。",
|
||||
"searching": "検索中...",
|
||||
"noNotesFoundForContext": "この質問に関連するノートが見つかりませんでした。一般的な知識でお答えください。"
|
||||
"noNotesFoundForContext": "この質問に関連するノートが見つかりませんでした。一般的な知識でお答えください。",
|
||||
"webSearch": "ウェブ検索"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "ラボ",
|
||||
|
||||
@@ -1360,7 +1360,8 @@
|
||||
"renameError": "이름 변경 중 오류",
|
||||
"welcome": "노트를 종합하고, 새로운 아이디어를 생성하거나, 노트북에 대해 논의하는 데 도움을 드릴 수 있습니다.",
|
||||
"searching": "검색 중...",
|
||||
"noNotesFoundForContext": "이 질문에 대한 관련 노트를 찾을 수 없습니다. 일반 지식으로 답변하세요."
|
||||
"noNotesFoundForContext": "이 질문에 대한 관련 노트를 찾을 수 없습니다. 일반 지식으로 답변하세요.",
|
||||
"webSearch": "웹 검색"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "랩",
|
||||
|
||||
@@ -1405,7 +1405,8 @@
|
||||
"renameError": "Fout bij hernoemen",
|
||||
"welcome": "Ik ben hier om u te helpen uw notities samen te vatten, nieuwe ideeën te genereren of uw notitieboeken te bespreken.",
|
||||
"searching": "Zoeken...",
|
||||
"noNotesFoundForContext": "Geen relevante notities gevonden voor deze vraag. Beantwoord met je algemene kennis."
|
||||
"noNotesFoundForContext": "Geen relevante notities gevonden voor deze vraag. Beantwoord met je algemene kennis.",
|
||||
"webSearch": "Zoeken op het web"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "Het Lab",
|
||||
|
||||
@@ -1427,7 +1427,8 @@
|
||||
"renameError": "Błąd zmiany nazwy",
|
||||
"welcome": "Jestem tutaj, aby pomóc Ci podsumować notatki, generować nowe pomysły lub omawiać Twoje notatniki.",
|
||||
"searching": "Wyszukiwanie...",
|
||||
"noNotesFoundForContext": "Nie znaleziono odpowiednich notatek dla tego pytania. Odpowiedz używając ogólnej wiedzy."
|
||||
"noNotesFoundForContext": "Nie znaleziono odpowiednich notatek dla tego pytania. Odpowiedz używając ogólnej wiedzy.",
|
||||
"webSearch": "Wyszukiwanie w sieci"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "Laboratorium",
|
||||
|
||||
@@ -1355,7 +1355,8 @@
|
||||
"renameError": "Erro ao renomear",
|
||||
"welcome": "Estou aqui para ajudá-lo a sintetizar suas notas, gerar novas ideias ou discutir seus cadernos.",
|
||||
"searching": "Pesquisando...",
|
||||
"noNotesFoundForContext": "Nenhuma nota relevante encontrada para esta pergunta. Responda com seu conhecimento geral."
|
||||
"noNotesFoundForContext": "Nenhuma nota relevante encontrada para esta pergunta. Responda com seu conhecimento geral.",
|
||||
"webSearch": "Pesquisa na web"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "O Laboratório",
|
||||
|
||||
@@ -1355,7 +1355,8 @@
|
||||
"renameError": "Ошибка переименования",
|
||||
"welcome": "Я здесь, чтобы помочь вам обобщить заметки, генерировать новые идеи или обсуждать ваши блокноты.",
|
||||
"searching": "Поиск...",
|
||||
"noNotesFoundForContext": "Не найдено заметок по этому вопросу. Ответьте, используя свои общие знания."
|
||||
"noNotesFoundForContext": "Не найдено заметок по этому вопросу. Ответьте, используя свои общие знания.",
|
||||
"webSearch": "Веб-поиск"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "Лаборатория",
|
||||
|
||||
@@ -1383,7 +1383,8 @@
|
||||
"renameError": "重命名时出错",
|
||||
"welcome": "我可以帮助您综合笔记、生成新想法或讨论您的笔记本。",
|
||||
"searching": "搜索中...",
|
||||
"noNotesFoundForContext": "未找到与此问题相关的笔记。请用你的常识回答。"
|
||||
"noNotesFoundForContext": "未找到与此问题相关的笔记。请用你的常识回答。",
|
||||
"webSearch": "网络搜索"
|
||||
},
|
||||
"labHeader": {
|
||||
"title": "实验室",
|
||||
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 12 KiB |