feat(ux): epic UX design improvements across agents, chat, notes, and i18n

Comprehensive UI/UX updates including agent card redesign, chat container
improvements, note editor enhancements, memory echo notifications, and
updated translations for all 15 locales.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-19 23:01:04 +02:00
parent c2a4c22e5f
commit 402e88b788
208 changed files with 493 additions and 318 deletions

View 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

View 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"

View File

@@ -185,20 +185,20 @@ export function AgentsPageClient({
{/* Agents grid */} {/* Agents grid */}
{agents.length > 0 && ( {agents.length > 0 && (
<div className="mb-10"> <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')} {t('agents.myAgents')}
</h3> </h3>
{/* Search and filter */} {/* Search and filter */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4"> <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"> <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 <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder={t('agents.searchPlaceholder')} 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>
<div className="flex items-center gap-1.5 flex-wrap"> <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 ${ className={`px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
typeFilter === opt.value typeFilter === opt.value
? 'bg-primary text-white' ? '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)} {t(opt.labelKey)}
@@ -232,8 +232,8 @@ export function AgentsPageClient({
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="w-10 h-10 text-slate-300 mb-3" /> <Search className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-slate-400">{t('agents.noResults')}</p> <p className="text-sm text-muted-foreground">{t('agents.noResults')}</p>
</div> </div>
)} )}
</div> </div>
@@ -242,9 +242,9 @@ export function AgentsPageClient({
{/* Empty state */} {/* Empty state */}
{agents.length === 0 && ( {agents.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center mb-10"> <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" /> <Bot className="w-16 h-16 text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-medium text-slate-500 mb-2">{t('agents.noAgents')}</h3> <h3 className="text-lg font-medium text-muted-foreground mb-2">{t('agents.noAgents')}</h3>
<p className="text-sm text-slate-400 max-w-sm"> <p className="text-sm text-muted-foreground max-w-sm">
{t('agents.noAgentsDescription')} {t('agents.noAgentsDescription')}
</p> </p>
</div> </div>

View File

@@ -19,7 +19,7 @@ export default async function AgentsPage() {
]) ])
return ( 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="flex-1 p-8 overflow-y-auto">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<AgentsPageClient <AgentsPageClient

View File

@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { ChatContainer } from '@/components/chat/chat-container' import { ChatContainer } from '@/components/chat/chat-container'
import { getConversations } from '@/app/actions/chat-actions' import { getConversations } from '@/app/actions/chat-actions'
import { getSystemConfig } from '@/lib/config'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Chat IA | Memento', title: 'Chat IA | Memento',
@@ -17,19 +18,26 @@ export default async function ChatPage() {
const userId = session.user.id const userId = session.user.id
// Fetch initial data // Fetch initial data
const [conversations, notebooks] = await Promise.all([ const [conversations, notebooks, config] = await Promise.all([
getConversations(), getConversations(),
prisma.notebook.findMany({ prisma.notebook.findMany({
where: { userId }, where: { userId },
orderBy: { order: 'asc' } 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 ( return (
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]"> <div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
<ChatContainer <ChatContainer
initialConversations={conversations} initialConversations={conversations}
notebooks={notebooks} notebooks={notebooks}
webSearchAvailable={webSearchAvailable}
/> />
</div> </div>
) )

View File

@@ -435,6 +435,7 @@ export async function createNote(data: {
isMarkdown?: boolean isMarkdown?: boolean
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
autoGenerated?: boolean autoGenerated?: boolean
aiProvider?: string
notebookId?: string | undefined // Assign note to a notebook if provided notebookId?: string | undefined // Assign note to a notebook if provided
skipRevalidation?: boolean // Option to prevent full page refresh for smooth optimistic UI updates 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, isMarkdown: data.isMarkdown || false,
size: data.size || 'small', size: data.size || 'small',
autoGenerated: data.autoGenerated || null, autoGenerated: data.autoGenerated || null,
aiProvider: data.aiProvider || null,
notebookId: data.notebookId || 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 toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) } 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 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 // Update note size WITHOUT revalidation - client uses optimistic updates
export async function updateSize(id: string, size: 'small' | 'medium' | 'large') { 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'); if (!session?.user?.id) throw new Error('Unauthorized');
const userId = session.user.id; const userId = session.user.id;
try { try {
const updates = ids.map((id: string, index: number) => // Verify all notes belong to the user before updating
prisma.note.update({ where: { id, userId }, data: { order: index } }) 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) await prisma.$transaction(updates)
return { success: true } return { success: true }

View File

@@ -5,6 +5,8 @@ import { semanticSearchService } from '@/lib/ai/services/semantic-search.service
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { auth } from '@/auth' import { auth } from '@/auth'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n' import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { stepCountIs } from 'ai'
export const maxDuration = 60 export const maxDuration = 60
@@ -45,11 +47,12 @@ export async function POST(req: Request) {
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport // 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
const body = await req.json() const body = await req.json()
const { messages: rawMessages, conversationId, notebookId, language } = body as { const { messages: rawMessages, conversationId, notebookId, language, webSearch } = body as {
messages: UIMessage[] messages: UIMessage[]
conversationId?: string conversationId?: string
notebookId?: string notebookId?: string
language?: string language?: string
webSearch?: boolean
} }
// Convert UIMessages to CoreMessages for streamText // Convert UIMessages to CoreMessages for streamText
@@ -143,7 +146,19 @@ export async function POST(req: Request) {
- Natural tone, neither corporate nor too casual. - Natural tone, neither corporate nor too casual.
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly. - 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. - 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: { 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.`, 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. - 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 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. - 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: { fa: {
contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشت‌ها موضوع را پوشش نمی‌دهند، بگویید و با دانش عمومی خود تکمیل کنید.`, 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: { 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.`, 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 ## Reglas de tono
- Tono natural, ni corporativo ni demasiado informal. - Tono natural, ni corporativo ni demasiado informal.
- Sin frases de introducción innecesarias. Responde directamente. - 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: { 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.`, 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 ## Tonregeln
- Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich. - Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich.
- Keine unnötigen Einleitungssätze. Antworte direkt. - 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: { 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.`, 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 ## Regole di tono
- Tono naturale, né aziendale né troppo informale. - Tono naturale, né aziendale né troppo informale.
- Nessuna frase introduttiva non necessaria. Rispondi direttamente. - 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 provider = getChatProvider(config)
const model = provider.getModel() 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 // 8. Save user message to DB before streaming
if (isNewMessage && lastIncoming) { if (isNewMessage && lastIncoming) {
await prisma.chatMessage.create({ await prisma.chatMessage.create({
@@ -276,6 +357,8 @@ ${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds
model, model,
system: systemPrompt, system: systemPrompt,
messages: allMessages, messages: allMessages,
tools: chatTools,
stopWhen: stepCountIs(5),
async onFinish({ text }) { async onFinish({ text }) {
// Save assistant message to DB after streaming completes // Save assistant message to DB after streaming completes
await prisma.chatMessage.create({ await prisma.chatMessage.create({

View File

@@ -52,10 +52,10 @@ interface AgentCardProps {
// --- Config --- // --- Config ---
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string }> = { 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' }, 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', bgColor: 'bg-purple-50 border-purple-200' }, 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', bgColor: 'bg-amber-50 border-amber-200' }, 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', bgColor: 'bg-green-50 border-green-200' }, 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> = { const frequencyKeys: Record<string, string> = {
@@ -142,10 +142,10 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
return ( return (
<div className={` <div className={`
bg-white rounded-xl border-2 transition-all overflow-hidden bg-card 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'} ${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="p-4">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
@@ -155,9 +155,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <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 && ( {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')} {t('agents.newBadge')}
</span> </span>
)} )}
@@ -171,16 +171,16 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
{agent.isEnabled ? ( {agent.isEnabled ? (
<ToggleRight className="w-6 h-6 text-primary" /> <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> </button>
</div> </div>
{agent.description && ( {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"> <span className="flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')} {t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
@@ -199,9 +199,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
{lastAction && ( {lastAction && (
<div className={` <div className={`
flex items-center gap-1.5 text-xs px-2 py-1 rounded-md mb-3 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 === 'success' ? 'bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400' : ''}
${lastAction.status === 'failure' ? 'bg-red-50 text-red-600' : ''} ${lastAction.status === 'failure' ? 'bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400' : ''}
${lastAction.status === 'running' ? 'bg-blue-50 text-blue-600' : ''} ${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 === 'success' && <CheckCircle2 className="w-3 h-3" />}
{lastAction.status === 'failure' && <XCircle 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 <button
onClick={handleRun} onClick={handleRun}
disabled={isRunning || !agent.isEnabled} 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')} title={t('agents.actions.run')}
> >
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />} {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 <button
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting} 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')} title={t('agents.actions.delete')}
> >
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />} {isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}

View File

@@ -21,7 +21,7 @@ function FieldHelp({ tooltip }: { tooltip: string }) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <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" /> <HelpCircle className="w-3.5 h-3.5" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@@ -61,6 +61,16 @@ const TOOL_PRESETS: Record<string, string[]> = {
custom: ['memory_search'], 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 --- // --- Component ---
export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps) { export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps) {
@@ -187,26 +197,26 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
return ( return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4"> <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 */} {/* 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 <input
type="text" type="text"
value={name} value={name}
onChange={e => setName(e.target.value)} 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')} placeholder={t('agents.form.namePlaceholder')}
required required
/> />
<button onClick={onCancel} className="p-1 rounded-md hover:bg-slate-100 ml-3"> <button onClick={onCancel} className="p-1 rounded-md hover:bg-accent ml-3">
<X className="w-5 h-5 text-slate-400" /> <X className="w-5 h-5 text-muted-foreground" />
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-5"> <form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Agent Type */} {/* Agent Type */}
<div> <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"> <div className="grid grid-cols-2 gap-2">
{agentTypes.map(at => ( {agentTypes.map(at => (
<button <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 text-left px-3 py-2.5 rounded-lg border-2 transition-all text-sm
${type === at.value ${type === at.value
? 'border-primary bg-primary/5 text-primary font-medium' ? '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="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> </button>
))} ))}
</div> </div>
@@ -230,12 +240,12 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{/* Research Topic (researcher only) — replaces Description for this type */} {/* Research Topic (researcher only) — replaces Description for this type */}
{type === 'researcher' && ( {type === 'researcher' && (
<div> <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 <input
type="text" type="text"
value={description} value={description}
onChange={e => setDescription(e.target.value)} 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')} placeholder={t('agents.form.researchTopicPlaceholder')}
/> />
</div> </div>
@@ -244,12 +254,12 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{/* Description (for non-researcher types) */} {/* Description (for non-researcher types) */}
{type !== 'researcher' && ( {type !== 'researcher' && (
<div> <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 <input
type="text" type="text"
value={description} value={description}
onChange={e => setDescription(e.target.value)} 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')} placeholder={t('agents.form.descriptionPlaceholder')}
/> />
</div> </div>
@@ -258,7 +268,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{/* URLs (scraper and custom only — researcher uses search, not URLs) */} {/* URLs (scraper and custom only — researcher uses search, not URLs) */}
{(type === 'scraper' || type === 'custom') && ( {(type === 'scraper' || type === 'custom') && (
<div> <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')} /> {t('agents.form.urlsLabel')}<FieldHelp tooltip={t('agents.help.tooltips.urls')} />
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@@ -268,14 +278,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
type="url" type="url"
value={url} value={url}
onChange={e => updateUrl(i, e.target.value)} 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" placeholder="https://example.com"
/> />
{urls.length > 1 && ( {urls.length > 1 && (
<button <button
type="button" type="button"
onClick={() => removeUrl(i)} 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" /> <Trash2 className="w-4 h-4" />
</button> </button>
@@ -297,11 +307,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{/* Source Notebook (monitor only) */} {/* Source Notebook (monitor only) */}
{showSourceNotebook && ( {showSourceNotebook && (
<div> <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 <select
value={sourceNotebookId} value={sourceNotebookId}
onChange={e => setSourceNotebookId(e.target.value)} 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> <option value="">{t('agents.form.selectNotebook')}</option>
{notebooks.map(nb => ( {notebooks.map(nb => (
@@ -315,11 +325,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{/* Target Notebook */} {/* Target Notebook */}
<div> <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 <select
value={targetNotebookId} value={targetNotebookId}
onChange={e => setTargetNotebookId(e.target.value)} 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> <option value="">{t('agents.form.inbox')}</option>
{notebooks.map(nb => ( {notebooks.map(nb => (
@@ -332,11 +342,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{/* Frequency */} {/* Frequency */}
<div> <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 <select
value={frequency} value={frequency}
onChange={e => setFrequency(e.target.value)} 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="manual">{t('agents.frequencies.manual')}</option>
<option value="hourly">{t('agents.frequencies.hourly')}</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 ${ className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
notifyEmail notifyEmail
? 'border-primary bg-primary/5' ? '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="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-700">{t('agents.form.notifyEmail')}</div> <div className={toggleOffLabel}>{t('agents.form.notifyEmail')}</div>
<div className="text-xs text-slate-400">{t('agents.form.notifyEmailHint')}</div> <div className={toggleOffHint}>{t('agents.form.notifyEmailHint')}</div>
</div> </div>
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${notifyEmail ? 'bg-primary' : 'bg-slate-200'}`}> <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-white rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} /> <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>
</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 ${ className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
includeImages includeImages
? 'border-primary bg-primary/5' ? '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="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-700">{t('agents.form.includeImages')}</div> <div className={toggleOffLabel}>{t('agents.form.includeImages')}</div>
<div className="text-xs text-slate-400">{t('agents.form.includeImagesHint')}</div> <div className={toggleOffHint}>{t('agents.form.includeImagesHint')}</div>
</div> </div>
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${includeImages ? 'bg-primary' : 'bg-slate-200'}`}> <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-white rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} /> <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>
</div> </div>
@@ -388,7 +398,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<button <button
type="button" type="button"
onClick={() => setShowAdvanced(!showAdvanced)} 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" />} {showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{t('agents.form.advancedMode')} {t('agents.form.advancedMode')}
@@ -398,23 +408,23 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{showAdvanced && ( {showAdvanced && (
<> <>
<div> <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')} {t('agents.form.instructions')}
<FieldHelp tooltip={t('agents.help.tooltips.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> </label>
<textarea <textarea
value={role} value={role}
onChange={e => setRole(e.target.value)} onChange={e => setRole(e.target.value)}
rows={3} 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')} placeholder={t('agents.form.instructionsPlaceholder')}
/> />
</div> </div>
{/* Advanced: Tools */} {/* Advanced: Tools */}
<div> <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"> <div className="grid grid-cols-2 gap-2">
{availableTools.map(at => { {availableTools.map(at => {
const Icon = at.icon 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 flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all text-left
${isSelected ${isSelected
? 'border-primary bg-primary/5 text-primary font-medium' ? '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" /> <Icon className="w-4 h-4 flex-shrink-0" />
<span>{t(at.labelKey)}</span> <span>{t(at.labelKey)}</span>
{at.external && !isSelected && ( {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> </button>
) )
})} })}
</div> </div>
{selectedTools.length > 0 && ( {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 })} {t('agents.tools.selected', { count: selectedTools.length })}
</p> </p>
)} )}
@@ -454,9 +464,9 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{/* Advanced: Max Steps */} {/* Advanced: Max Steps */}
{selectedTools.length > 0 && ( {selectedTools.length > 0 && (
<div> <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')} /> {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> </label>
<input <input
type="range" type="range"
@@ -466,7 +476,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
onChange={e => setMaxSteps(Number(e.target.value))} onChange={e => setMaxSteps(Number(e.target.value))}
className="w-full accent-primary" 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>3</span>
<span>25</span> <span>25</span>
</div> </div>
@@ -480,14 +490,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<button <button
type="button" type="button"
onClick={onCancel} 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')} {t('agents.form.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
disabled={isSaving} 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')} {isSaving ? t('agents.form.saving') : agent ? t('agents.form.save') : t('agents.form.create')}
</button> </button>

View File

@@ -66,15 +66,15 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
return ( return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4"> <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 */} {/* 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> <div>
<h3 className="font-semibold text-slate-800">{t('agents.runLog.title')}</h3> <h3 className="font-semibold text-card-foreground">{t('agents.runLog.title')}</h3>
<p className="text-xs text-slate-400">{agentName}</p> <p className="text-xs text-muted-foreground">{agentName}</p>
</div> </div>
<button onClick={onClose} className="p-1 rounded-md hover:bg-slate-100"> <button onClick={onClose} className="p-1 rounded-md hover:bg-accent">
<X className="w-5 h-5 text-slate-400" /> <X className="w-5 h-5 text-muted-foreground" />
</button> </button>
</div> </div>
@@ -82,12 +82,12 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
<div className="flex-1 overflow-y-auto p-4 space-y-2"> <div className="flex-1 overflow-y-auto p-4 space-y-2">
{loading && ( {loading && (
<div className="flex items-center justify-center py-8"> <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> </div>
)} )}
{!loading && actions.length === 0 && ( {!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')} {t('agents.runLog.noHistory')}
</p> </p>
)} )}
@@ -103,10 +103,10 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
key={action.id} key={action.id}
className={` className={`
p-3 rounded-lg border p-3 rounded-lg border
${action.status === 'success' ? 'bg-green-50/50 border-green-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 border-red-100' : ''} ${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 border-blue-100' : ''} ${action.status === 'running' ? 'bg-blue-50/50 dark:bg-blue-950/50 border-blue-100 dark:border-blue-900' : ''}
${action.status === 'pending' ? 'bg-slate-50 border-slate-100' : ''} ${action.status === 'pending' ? 'bg-muted border-border' : ''}
`} `}
> >
<div className="flex items-start gap-3"> <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 === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{action.status === 'failure' && <XCircle className="w-4 h-4 text-red-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 === '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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between"> <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)} {t(statusKeys[action.status] || action.status)}
</span> </span>
<span className="text-xs text-slate-400"> <span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })} {formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
</span> </span>
</div> </div>
{action.log && ( {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>
</div> </div>
@@ -142,13 +142,13 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
<div className="mt-2 space-y-2 pl-2"> <div className="mt-2 space-y-2 pl-2">
{toolSteps.map((step, i) => ( {toolSteps.map((step, i) => (
<div key={i} className="text-xs border-l-2 border-primary/30 pl-2 py-1"> <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 && ( {step.toolCalls && step.toolCalls.length > 0 && (
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
{step.toolCalls.map((tc, j) => ( {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="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)} {JSON.stringify(tc.args).substring(0, 80)}
</span> </span>
</div> </div>

View File

@@ -14,19 +14,22 @@ import { useLanguage } from '@/lib/i18n'
interface ChatContainerProps { interface ChatContainerProps {
initialConversations: any[] initialConversations: any[]
notebooks: any[] notebooks: any[]
webSearchAvailable?: boolean
} }
export function ChatContainer({ initialConversations, notebooks }: ChatContainerProps) { export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const [conversations, setConversations] = useState(initialConversations) const [conversations, setConversations] = useState(initialConversations)
const [currentId, setCurrentId] = useState<string | null>(null) const [currentId, setCurrentId] = useState<string | null>(null)
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined) const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([]) const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false) const [isLoadingHistory, setIsLoadingHistory] = useState(false)
// Prevents the useEffect from loading an empty conversation // Prevents the useEffect from loading an empty conversation
// when we just created one via createConversation() // when we just created one via createConversation()
const skipHistoryLoad = useRef(false) const skipHistoryLoad = useRef(false)
const scrollRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({ const transport = useRef(new DefaultChatTransport({
api: '/api/chat', api: '/api/chat',
@@ -129,6 +132,7 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
conversationId: convId, conversationId: convId,
notebookId: notebookId || selectedNotebook || undefined, notebookId: notebookId || selectedNotebook || undefined,
language, language,
webSearch: webSearchEnabled,
}, },
} }
) )
@@ -139,6 +143,7 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
setMessages([]) setMessages([])
setHistoryMessages([]) setHistoryMessages([])
setSelectedNotebook(undefined) setSelectedNotebook(undefined)
setWebSearchEnabled(false)
} }
const handleDeleteConversation = async (id: string) => { const handleDeleteConversation = async (id: string) => {
@@ -158,6 +163,13 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
? messages ? messages
: historyMessages : historyMessages
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [displayMessages])
return ( return (
<div className="flex-1 flex overflow-hidden bg-white dark:bg-[#1a1c22]"> <div className="flex-1 flex overflow-hidden bg-white dark:bg-[#1a1c22]">
<ChatSidebar <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 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} /> <ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
</div> </div>
@@ -180,6 +192,9 @@ export function ChatContainer({ initialConversations, notebooks }: ChatContainer
isLoading={isLoading} isLoading={isLoading}
notebooks={notebooks} notebooks={notebooks}
currentNotebookId={selectedNotebook || null} currentNotebookId={selectedNotebook || null}
webSearchEnabled={webSearchEnabled}
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
webSearchAvailable={webSearchAvailable}
/> />
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useRef, useEffect } from 'react' 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 { getNotebookIcon } from '@/lib/notebook-icon'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@@ -21,9 +21,12 @@ interface ChatInputProps {
isLoading?: boolean isLoading?: boolean
notebooks: any[] notebooks: any[]
currentNotebookId?: string | null 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 { t } = useLanguage()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined) 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"> <div className="flex items-center justify-between px-3 pb-3 pt-1">
{/* Context Selector */} {/* Context Selector */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select <Select
value={selectedNotebook || 'global'} value={selectedNotebook || 'global'}
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)} 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"> <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> </SelectContent>
</Select> </Select>
{selectedNotebook && ( {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"> <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')} {t('chat.active')}
</Badge> </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> </div>
{/* Send Button */} {/* Send Button */}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' 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 { Button } from '@/components/ui/button'
import { Sparkles, ThumbsUp, ThumbsDown, GitMerge, X } from 'lucide-react' import { Sparkles, ThumbsUp, ThumbsDown, GitMerge, X } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -84,12 +84,12 @@ export function ComparisonModal({
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" /> <Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div> </div>
<div> <div>
<h2 className="text-xl font-semibold"> <DialogTitle className="text-xl font-semibold">
{t('memoryEcho.comparison.title')} {t('memoryEcho.comparison.title')}
</h2> </DialogTitle>
<p className="text-sm text-gray-600 dark:text-gray-400"> <DialogDescription className="text-sm text-gray-600 dark:text-gray-400">
{t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })} {t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })}
</p> </DialogDescription>
</div> </div>
</div> </div>
<button <button

View File

@@ -310,7 +310,7 @@ export function ConnectionsOverlay({
</div> </div>
)} )}
{/* Footer - Action */} {/* Footer - Actions */}
<div className="px-6 py-4 border-t dark:border-zinc-700"> <div className="px-6 py-4 border-t dark:border-zinc-700">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useRef } from 'react' 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 { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
@@ -114,8 +114,22 @@ export function FusionModal({
try { try {
// Parse the preview into title and content // Parse the preview into title and content
const lines = fusionPreview.split('\n') const lines = fusionPreview.split('\n')
const title = lines[0].replace(/^#+\s*/, '').trim() let title = ''
const content = lines.slice(1).join('\n').trim() 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( await onConfirmFusion(
{ title, content }, { title, content },
@@ -132,7 +146,7 @@ export function FusionModal({
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <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 */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700"> <div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
<div className="flex items-center gap-3"> <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" /> <Link2 className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div> </div>
<div> <div>
<h2 className="text-xl font-semibold"> <DialogTitle className="text-xl font-semibold">
{t('memoryEcho.fusion.title')} {t('memoryEcho.fusion.title')}
</h2> </DialogTitle>
<p className="text-sm text-gray-600 dark:text-gray-400"> <DialogDescription className="text-sm text-gray-600 dark:text-gray-400">
{t('memoryEcho.fusion.mergeNotes', { count: selectedNoteIds.length })} {t('memoryEcho.fusion.mergeNotes', { count: selectedNoteIds.length })}
</p> </DialogDescription>
</div> </div>
</div> </div>
<button <button
onClick={onClose} 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> </button>
</div> </div>

View File

@@ -209,6 +209,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
type: 'text', type: 'text',
isMarkdown: true, isMarkdown: true,
autoGenerated: true, autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined notebookId: fusionNotes[0].notebookId ?? undefined
}) })
if (options.archiveOriginals) { if (options.archiveOriginals) {

View File

@@ -462,6 +462,21 @@ export const NoteCard = memo(function NoteCard({
/> />
)} )}
{/* 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 */} {/* Title */}
{optimisticNote.title && ( {optimisticNote.title && (
<h3 className="text-base font-medium mb-2 pr-10 text-foreground"> <h3 className="text-base font-medium mb-2 pr-10 text-foreground">
@@ -684,6 +699,7 @@ export const NoteCard = memo(function NoteCard({
type: 'text', type: 'text',
isMarkdown: true, isMarkdown: true,
autoGenerated: true, autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined notebookId: fusionNotes[0].notebookId ?? undefined
}) })
if (options.archiveOriginals) { if (options.archiveOriginals) {

View File

@@ -1150,6 +1150,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
type: 'text', type: 'text',
isMarkdown: true, // AI generates markdown content isMarkdown: true, // AI generates markdown content
autoGenerated: true, // Mark as AI-generated fused note 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 notebookId: fusionNotes[0].notebookId ?? undefined // Keep the notebook from the first note, convert null to undefined
}) })

View File

@@ -27,8 +27,6 @@ import {
toggleArchive, toggleArchive,
updateColor, updateColor,
deleteNote, deleteNote,
removeImageFromNote,
leaveSharedNote,
createNote, createNote,
} from '@/app/actions/notes' } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape' import { fetchLinkMetadata } from '@/app/actions/scrape'
@@ -55,8 +53,6 @@ import {
RotateCcw, RotateCcw,
Languages, Languages,
ChevronRight, ChevronRight,
Copy,
LogOut,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { MarkdownContent } from '@/components/markdown-content' import { MarkdownContent } from '@/components/markdown-content'
@@ -108,12 +104,10 @@ export function NoteInlineEditor({
defaultPreviewMode = false, defaultPreviewMode = false,
}: NoteInlineEditorProps) { }: NoteInlineEditorProps) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const { labels: globalLabels, addLabel, refreshLabels } = useLabels() const { labels: globalLabels, addLabel } = useLabels()
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
const { triggerRefresh } = useNoteRefresh() const { triggerRefresh } = useNoteRefresh()
const isSharedNote = !!(note as any)._isShared
// ── Local edit state ────────────────────────────────────────────────────── // ── Local edit state ──────────────────────────────────────────────────────
const [title, setTitle] = useState(note.title || '') const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content || '') 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 changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) } 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 // Link dialog
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false) const [showLinkInput, setShowLinkInput] = useState(false)
@@ -272,78 +238,30 @@ export function NoteInlineEditor({
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true }) await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase()) const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) { if (!globalExists) {
try { try { await addLabel(tag) } catch {}
await addLabel(tag)
// Refresh labels to get the new color assignment
await refreshLabels()
} catch {}
} }
toast.success(t('ai.tagAdded', { tag })) toast.success(t('ai.tagAdded', { tag }))
} }
} }
const handleRemoveLabel = async (label: string) => { const fetchNotesByIds = async (noteIds: string[]) => {
const newLabels = (note.labels || []).filter((l) => l !== label) const fetched = await Promise.all(noteIds.map(async (id) => {
// Optimistic UI try {
onChange?.(note.id, { labels: newLabels }) const res = await fetch(`/api/notes/${id}`)
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true }) if (!res.ok) return null
toast.success(t('labels.labelRemoved', { label })) const data = await res.json()
} return data.success && data.data ? data.data : null
} catch { return null }
// ── Shared note actions ──────────────────────────────────────────────────── }))
const handleMakeCopy = async () => { return fetched.filter((n: any) => n !== null) as Array<Partial<Note>>
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 handleMergeNotes = async (noteIds: string[]) => { const handleMergeNotes = async (noteIds: string[]) => {
const fetched = await Promise.all(noteIds.map(async (id) => { setFusionNotes(await fetchNotesByIds(noteIds))
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 handleCompareNotes = async (noteIds: string[]) => { const handleCompareNotes = async (noteIds: string[]) => {
const fetched = await Promise.all(noteIds.map(async (id) => { setComparisonNotes(await fetchNotesByIds(noteIds))
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>>)
} }
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => { 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', type: 'text',
isMarkdown: true, isMarkdown: true,
autoGenerated: true, autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined notebookId: fusionNotes[0].notebookId ?? undefined
}) })
if (options.archiveOriginals) { if (options.archiveOriginals) {
for (const n of fusionNotes) { 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')) toast.success(t('toast.notesFusionSuccess'))
@@ -395,21 +314,10 @@ export function NoteInlineEditor({
} }
const handleDelete = () => { const handleDelete = () => {
toast(t('notes.confirmDelete'), { if (!confirm(t('notes.confirmDelete'))) return
action: { startTransition(async () => {
label: t('notes.delete'), await deleteNote(note.id)
onClick: () => { onDelete?.(note.id)
startTransition(async () => {
await deleteNote(note.id)
onDelete?.(note.id)
})
},
},
cancel: {
label: t('common.cancel'),
onClick: () => {},
},
duration: 5000,
}) })
} }
@@ -437,7 +345,7 @@ export function NoteInlineEditor({
const handleRemoveImage = async (index: number) => { const handleRemoveImage = async (index: number) => {
const newImages = (note.images || []).filter((_, i) => i !== index) const newImages = (note.images || []).filter((_, i) => i !== index)
onChange?.(note.id, { images: newImages }) onChange?.(note.id, { images: newImages })
await removeImageFromNote(note.id, index) await updateNote(note.id, { images: newImages })
} }
// ── Link ────────────────────────────────────────────────────────────────── // ── Link ──────────────────────────────────────────────────────────────────
@@ -581,27 +489,7 @@ export function NoteInlineEditor({
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
{/* ── Shared note banner ──────────────────────────────────────────── */} {/* ── Toolbar ────────────────────────────────────────────────────────── */}
{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 && (
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2"> <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"> <div className="flex items-center gap-1">
{/* Image upload */} {/* Image upload */}
@@ -789,6 +677,12 @@ export function NoteInlineEditor({
)} )}
</span> </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 */} {/* Color picker */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -835,7 +729,6 @@ export function NoteInlineEditor({
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
)}
{/* ── Link input bar (inline) ───────────────────────────────────────── */} {/* ── Link input bar (inline) ───────────────────────────────────────── */}
{showLinkInput && ( {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"> <div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
{/* Existing labels */} {/* Existing labels */}
{(note.labels ?? []).map((label) => ( {(note.labels ?? []).map((label) => (
<LabelBadge <LabelBadge key={label} label={label} />
key={label}
label={label}
onRemove={() => handleRemoveLabel(label)}
/>
))} ))}
{/* AI-suggested tags inline with labels */} {/* AI-suggested tags inline with labels */}
<GhostTags <GhostTags
@@ -891,7 +780,6 @@ export function NoteInlineEditor({
placeholder={t('notes.titlePlaceholder') || 'Titre…'} placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title} value={title}
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }} onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
readOnly={isSharedNote}
/> />
{/* AI title suggestion — show when title is empty and there's content */} {/* AI title suggestion — show when title is empty and there's content */}
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && ( {!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
@@ -976,21 +864,17 @@ export function NoteInlineEditor({
<MarkdownContent content={content || ''} /> <MarkdownContent content={content || ''} />
</div> </div>
) : ( ) : (
<> <textarea
<textarea dir="auto"
ref={textAreaRef} className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
dir="auto" placeholder={isMarkdown
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40" ? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
placeholder={isMarkdown : t('notes.takeNote') || 'Écris quelque chose…'
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…' }
: t('notes.takeNote') || 'Écris quelque chose…' value={content}
} onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
value={content} style={{ minHeight: '200px' }}
onChange={(e) => { changeContent(e.target.value); scheduleSave() }} />
readOnly={isSharedNote}
style={{ minHeight: '200px' }}
/>
</>
)} )}
{/* Ghost tag suggestions are now shown in the top labels strip */} {/* Ghost tag suggestions are now shown in the top labels strip */}
@@ -1049,17 +933,18 @@ export function NoteInlineEditor({
</div> </div>
)} )}
</div> </div>
{/* ── Memory Echo Connections Section (not for shared notes) ── */}
{!isSharedNote && (
<EditorConnectionsSection
noteId={note.id}
onMergeNotes={handleMergeNotes}
onCompareNotes={handleCompareNotes}
/>
)}
</div> </div>
{/* ── Memory Echo Connections Section ── */}
<EditorConnectionsSection
noteId={note.id}
onOpenNote={(connNoteId) => {
window.open(`/?note=${connNoteId}`, '_blank')
}}
onCompareNotes={handleCompareNotes}
onMergeNotes={handleMergeNotes}
/>
{/* ── Footer ───────────────────────────────────────────────────────────── */} {/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2"> <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"> <div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">

View File

@@ -259,7 +259,7 @@ async function executeScraperAgent(
const r = articleResults[i] const r = articleResults[i]
if (r.status === 'fulfilled' && r.value) { if (r.status === 'fulfilled' && r.value) {
const article = articlesToScrape[i] 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)}`) scrapedParts.push(`## ${article.title}\n_Source: ${article.link}_${dateStr}\n\n${r.value.substring(0, 3000)}`)
sourceLinks.push(article.link) sourceLinks.push(article.link)
articleCount++ articleCount++

View File

@@ -14,12 +14,13 @@ toolRegistry.register({
isInternal: true, isInternal: true,
buildTool: (ctx) => buildTool: (ctx) =>
tool({ 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({ inputSchema: z.object({
query: z.string().describe('The search query'), query: z.string().describe('The search query'),
limit: z.number().optional().describe('Max results to return (default 5)').default(5), 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 { try {
// Keyword fallback search using Prisma // Keyword fallback search using Prisma
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2) const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)
@@ -31,6 +32,7 @@ toolRegistry.register({
const notes = await prisma.note.findMany({ const notes = await prisma.note.findMany({
where: { where: {
userId: ctx.userId, userId: ctx.userId,
...(notebookId ? { notebookId } : {}),
...(conditions.length > 0 ? { OR: conditions } : {}), ...(conditions.length > 0 ? { OR: conditions } : {}),
isArchived: false, isArchived: false,
trashedAt: null, trashedAt: null,

View File

@@ -9,8 +9,11 @@ import { z } from 'zod'
export interface ToolContext { export interface ToolContext {
userId: string userId: string
agentId: string agentId?: string
actionId: string actionId?: string
conversationId?: string
notebookId?: string
webSearch?: boolean
config: Record<string, string> config: Record<string, string>
} }
@@ -43,6 +46,29 @@ class ToolRegistry {
return built 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 }> { getAvailableTools(): Array<{ name: string; description: string; isInternal: boolean }> {
return Array.from(this.tools.values()).map(t => ({ return Array.from(this.tools.values()).map(t => ({
name: t.name, name: t.name,

View File

@@ -75,6 +75,7 @@ export interface Note {
notebookId?: string | null; notebookId?: string | null;
notebook?: Notebook | null; notebook?: Notebook | null;
autoGenerated?: boolean | null; autoGenerated?: boolean | null;
aiProvider?: string | null;
// Search result metadata (optional) // Search result metadata (optional)
matchType?: 'exact' | 'related' | null; matchType?: 'exact' | 'related' | null;
searchScore?: number | null; searchScore?: number | null;

View File

@@ -77,13 +77,14 @@ export function getHashColor(name: string): LabelColorName {
} }
export function cosineSimilarity(vecA: number[], vecB: number[]): number { 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 dotProduct = 0;
let mA = 0; let mA = 0;
let mB = 0; let mB = 0;
for (let i = 0; i < vecA.length; i++) { for (let i = 0; i < minLen; i++) {
dotProduct += vecA[i] * vecB[i]; dotProduct += vecA[i] * vecB[i];
mA += vecA[i] * vecA[i]; mA += vecA[i] * vecA[i];
mB += vecB[i] * vecB[i]; mB += vecB[i] * vecB[i];

View File

@@ -1360,7 +1360,8 @@
"renameError": "خطأ في إعادة التسمية", "renameError": "خطأ في إعادة التسمية",
"welcome": "أنا هنا لمساعدتك في تلخيص ملاحظاتك أو إنشاء أفكار جديدة أو مناقشة دفاتر ملاحظاتك.", "welcome": "أنا هنا لمساعدتك في تلخيص ملاحظاتك أو إنشاء أفكار جديدة أو مناقشة دفاتر ملاحظاتك.",
"searching": "جاري البحث...", "searching": "جاري البحث...",
"noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة." "noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة.",
"webSearch": "بحث الويب"
}, },
"labHeader": { "labHeader": {
"title": "المختبر", "title": "المختبر",

View File

@@ -1383,7 +1383,8 @@
"renameError": "Fehler beim Umbenennen", "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.", "welcome": "Ich bin hier, um Ihnen zu helfen, Ihre Notizen zusammenzufassen, neue Ideen zu generieren oder Ihre Notizbücher zu besprechen.",
"searching": "Wird gesucht...", "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": { "labHeader": {
"title": "Das Labor", "title": "Das Labor",

View File

@@ -1392,7 +1392,8 @@
"renameError": "Error renaming", "renameError": "Error renaming",
"welcome": "I'm here to help you synthesize your notes, generate new ideas, or discuss your notebooks.", "welcome": "I'm here to help you synthesize your notes, generate new ideas, or discuss your notebooks.",
"searching": "Searching...", "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": { "labHeader": {
"title": "The Lab", "title": "The Lab",

View File

@@ -1355,7 +1355,8 @@
"renameError": "Error al renombrar", "renameError": "Error al renombrar",
"welcome": "Estoy aquí para ayudarte a sintetizar tus notas, generar nuevas ideas o discutir tus cuadernos.", "welcome": "Estoy aquí para ayudarte a sintetizar tus notas, generar nuevas ideas o discutir tus cuadernos.",
"searching": "Buscando...", "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": { "labHeader": {
"title": "El Laboratorio", "title": "El Laboratorio",

View File

@@ -1414,7 +1414,8 @@
"renameError": "خطا در تغییر نام", "renameError": "خطا در تغییر نام",
"welcome": "من اینجا هستم تا در خلاصه‌سازی یادداشت‌ها، تولید ایده‌های جدید یا بحث درباره دفترچه‌هایتان به شما کمک کنم.", "welcome": "من اینجا هستم تا در خلاصه‌سازی یادداشت‌ها، تولید ایده‌های جدید یا بحث درباره دفترچه‌هایتان به شما کمک کنم.",
"searching": "در حال جستجو...", "searching": "در حال جستجو...",
"noNotesFoundForContext": "هیچ یادداشت مرتبطی برای این سوال یافت نشد. با دانش عمومی خود پاسخ دهید." "noNotesFoundForContext": "هیچ یادداشت مرتبطی برای این سوال یافت نشد. با دانش عمومی خود پاسخ دهید.",
"webSearch": "جستجوی وب"
}, },
"labHeader": { "labHeader": {
"title": "آزمایشگاه", "title": "آزمایشگاه",

View File

@@ -1388,7 +1388,8 @@
"renameError": "Erreur lors du renommage", "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.", "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...", "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": { "labHeader": {
"title": "Le Lab", "title": "Le Lab",

View File

@@ -1360,7 +1360,8 @@
"renameError": "नाम बदलने में त्रुटि", "renameError": "नाम बदलने में त्रुटि",
"welcome": "मैं आपके नोट्स को संश्लेषित करने, नए विचार उत्पन्न करने या आपके नोटबुक पर चर्चा करने में मदद करने के लिए यहां हूं।", "welcome": "मैं आपके नोट्स को संश्लेषित करने, नए विचार उत्पन्न करने या आपके नोटबुक पर चर्चा करने में मदद करने के लिए यहां हूं।",
"searching": "खोज रहे हैं...", "searching": "खोज रहे हैं...",
"noNotesFoundForContext": "इस प्रश्न के लिए कोई प्रासंगिक नोट्स नहीं मिले। अपने सामान्य ज्ञान से उत्तर दें।" "noNotesFoundForContext": "इस प्रश्न के लिए कोई प्रासंगिक नोट्स नहीं मिले। अपने सामान्य ज्ञान से उत्तर दें।",
"webSearch": "वेब खोज"
}, },
"labHeader": { "labHeader": {
"title": "लैब", "title": "लैब",

View File

@@ -1405,7 +1405,8 @@
"renameError": "Errore durante la ridenominazione", "renameError": "Errore durante la ridenominazione",
"welcome": "Sono qui per aiutarti a sintetizzare le tue note, generare nuove idee o discutere i tuoi quaderni.", "welcome": "Sono qui per aiutarti a sintetizzare le tue note, generare nuove idee o discutere i tuoi quaderni.",
"searching": "Ricerca in corso...", "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": { "labHeader": {
"title": "Il Laboratorio", "title": "Il Laboratorio",

View File

@@ -1383,7 +1383,8 @@
"renameError": "名前変更エラー", "renameError": "名前変更エラー",
"welcome": "ノートの要約、新しいアイデアの生成、ノートブックについてのディスカッションなどをお手伝いします。", "welcome": "ノートの要約、新しいアイデアの生成、ノートブックについてのディスカッションなどをお手伝いします。",
"searching": "検索中...", "searching": "検索中...",
"noNotesFoundForContext": "この質問に関連するノートが見つかりませんでした。一般的な知識でお答えください。" "noNotesFoundForContext": "この質問に関連するノートが見つかりませんでした。一般的な知識でお答えください。",
"webSearch": "ウェブ検索"
}, },
"labHeader": { "labHeader": {
"title": "ラボ", "title": "ラボ",

View File

@@ -1360,7 +1360,8 @@
"renameError": "이름 변경 중 오류", "renameError": "이름 변경 중 오류",
"welcome": "노트를 종합하고, 새로운 아이디어를 생성하거나, 노트북에 대해 논의하는 데 도움을 드릴 수 있습니다.", "welcome": "노트를 종합하고, 새로운 아이디어를 생성하거나, 노트북에 대해 논의하는 데 도움을 드릴 수 있습니다.",
"searching": "검색 중...", "searching": "검색 중...",
"noNotesFoundForContext": "이 질문에 대한 관련 노트를 찾을 수 없습니다. 일반 지식으로 답변하세요." "noNotesFoundForContext": "이 질문에 대한 관련 노트를 찾을 수 없습니다. 일반 지식으로 답변하세요.",
"webSearch": "웹 검색"
}, },
"labHeader": { "labHeader": {
"title": "랩", "title": "랩",

View File

@@ -1405,7 +1405,8 @@
"renameError": "Fout bij hernoemen", "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.", "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...", "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": { "labHeader": {
"title": "Het Lab", "title": "Het Lab",

View File

@@ -1427,7 +1427,8 @@
"renameError": "Błąd zmiany nazwy", "renameError": "Błąd zmiany nazwy",
"welcome": "Jestem tutaj, aby pomóc Ci podsumować notatki, generować nowe pomysły lub omawiać Twoje notatniki.", "welcome": "Jestem tutaj, aby pomóc Ci podsumować notatki, generować nowe pomysły lub omawiać Twoje notatniki.",
"searching": "Wyszukiwanie...", "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": { "labHeader": {
"title": "Laboratorium", "title": "Laboratorium",

View File

@@ -1355,7 +1355,8 @@
"renameError": "Erro ao renomear", "renameError": "Erro ao renomear",
"welcome": "Estou aqui para ajudá-lo a sintetizar suas notas, gerar novas ideias ou discutir seus cadernos.", "welcome": "Estou aqui para ajudá-lo a sintetizar suas notas, gerar novas ideias ou discutir seus cadernos.",
"searching": "Pesquisando...", "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": { "labHeader": {
"title": "O Laboratório", "title": "O Laboratório",

View File

@@ -1355,7 +1355,8 @@
"renameError": "Ошибка переименования", "renameError": "Ошибка переименования",
"welcome": "Я здесь, чтобы помочь вам обобщить заметки, генерировать новые идеи или обсуждать ваши блокноты.", "welcome": "Я здесь, чтобы помочь вам обобщить заметки, генерировать новые идеи или обсуждать ваши блокноты.",
"searching": "Поиск...", "searching": "Поиск...",
"noNotesFoundForContext": "Не найдено заметок по этому вопросу. Ответьте, используя свои общие знания." "noNotesFoundForContext": "Не найдено заметок по этому вопросу. Ответьте, используя свои общие знания.",
"webSearch": "Веб-поиск"
}, },
"labHeader": { "labHeader": {
"title": "Лаборатория", "title": "Лаборатория",

View File

@@ -1383,7 +1383,8 @@
"renameError": "重命名时出错", "renameError": "重命名时出错",
"welcome": "我可以帮助您综合笔记、生成新想法或讨论您的笔记本。", "welcome": "我可以帮助您综合笔记、生成新想法或讨论您的笔记本。",
"searching": "搜索中...", "searching": "搜索中...",
"noNotesFoundForContext": "未找到与此问题相关的笔记。请用你的常识回答。" "noNotesFoundForContext": "未找到与此问题相关的笔记。请用你的常识回答。",
"webSearch": "网络搜索"
}, },
"labHeader": { "labHeader": {
"title": "实验室", "title": "实验室",

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Some files were not shown because too many files have changed in this diff Show More