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>
47
.playwright-mcp/console-2026-04-19T18-53-32-857Z.log
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
[ 242ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:3000/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2F:0
|
||||||
|
[ 274ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 312ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 343ms] [LOG] [Prisma] Models loaded: @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 426ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 437ms] [LOG] [Fast Refresh] done in 112ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 180853ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 180883ms] [LOG] [Fast Refresh] done in 131ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 205199ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 205245ms] [LOG] [Fast Refresh] done in 50ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 205472ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 205472ms] [LOG] [Fast Refresh] done in 104ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 218086ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 218512ms] [LOG] [Fast Refresh] done in 150ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 218619ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 218629ms] [LOG] [Fast Refresh] done in 111ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 226896ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 226973ms] [LOG] [Fast Refresh] done in 41ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 250168ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 250197ms] [LOG] [Fast Refresh] done in 44ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300156ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300662ms] [LOG] [Fast Refresh] done in 206ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300662ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300673ms] [LOG] [Fast Refresh] done in 9ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300673ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300673ms] [LOG] [Fast Refresh] done in 1ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300674ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300674ms] [LOG] [Fast Refresh] done in 0ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300775ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 300797ms] [LOG] [Fast Refresh] done in 123ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 330315ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 330346ms] [LOG] [Fast Refresh] done in 132ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 494083ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 494099ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 494128ms] [LOG] [Prisma] Models loaded: @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 494533ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 494534ms] [LOG] [Fast Refresh] done in 312ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 583684ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 583685ms] [LOG] [Fast Refresh] done in 102ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 612080ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 612106ms] [LOG] [Fast Refresh] done in 50ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 620251ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 620280ms] [LOG] [Fast Refresh] done in 42ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 628266ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 628300ms] [LOG] [Fast Refresh] done in 48ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 628402ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
|
[ 628423ms] [LOG] [Fast Refresh] done in 122ms @ http://localhost:3000/_next/static/chunks/0sqx_next_dist_0ksjl25._.js:2431
|
||||||
21
.playwright-mcp/page-2026-04-19T18-53-33-247Z.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- main [ref=e4]:
|
||||||
|
- generic [ref=e7]:
|
||||||
|
- heading "Sign in to your account" [level=1] [ref=e8]
|
||||||
|
- generic [ref=e9]:
|
||||||
|
- generic [ref=e10]:
|
||||||
|
- generic [ref=e11]: Email
|
||||||
|
- textbox "Email" [ref=e13]:
|
||||||
|
- /placeholder: Enter your email address
|
||||||
|
- generic [ref=e14]:
|
||||||
|
- generic [ref=e15]: Password
|
||||||
|
- textbox "Password" [ref=e17]:
|
||||||
|
- /placeholder: Enter your password
|
||||||
|
- link "Forgot password?" [ref=e19] [cursor=pointer]:
|
||||||
|
- /url: /forgot-password
|
||||||
|
- button "Sign In" [ref=e20]
|
||||||
|
- generic [ref=e22]:
|
||||||
|
- text: Don't have an account?
|
||||||
|
- link "Sign Up" [ref=e23] [cursor=pointer]:
|
||||||
|
- /url: /register
|
||||||
|
- region "Notifications alt+T"
|
||||||
@@ -185,20 +185,20 @@ export function AgentsPageClient({
|
|||||||
{/* Agents grid */}
|
{/* Agents 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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" />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -1360,7 +1360,8 @@
|
|||||||
"renameError": "خطأ في إعادة التسمية",
|
"renameError": "خطأ في إعادة التسمية",
|
||||||
"welcome": "أنا هنا لمساعدتك في تلخيص ملاحظاتك أو إنشاء أفكار جديدة أو مناقشة دفاتر ملاحظاتك.",
|
"welcome": "أنا هنا لمساعدتك في تلخيص ملاحظاتك أو إنشاء أفكار جديدة أو مناقشة دفاتر ملاحظاتك.",
|
||||||
"searching": "جاري البحث...",
|
"searching": "جاري البحث...",
|
||||||
"noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة."
|
"noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة.",
|
||||||
|
"webSearch": "بحث الويب"
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "المختبر",
|
"title": "المختبر",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1414,7 +1414,8 @@
|
|||||||
"renameError": "خطا در تغییر نام",
|
"renameError": "خطا در تغییر نام",
|
||||||
"welcome": "من اینجا هستم تا در خلاصهسازی یادداشتها، تولید ایدههای جدید یا بحث درباره دفترچههایتان به شما کمک کنم.",
|
"welcome": "من اینجا هستم تا در خلاصهسازی یادداشتها، تولید ایدههای جدید یا بحث درباره دفترچههایتان به شما کمک کنم.",
|
||||||
"searching": "در حال جستجو...",
|
"searching": "در حال جستجو...",
|
||||||
"noNotesFoundForContext": "هیچ یادداشت مرتبطی برای این سوال یافت نشد. با دانش عمومی خود پاسخ دهید."
|
"noNotesFoundForContext": "هیچ یادداشت مرتبطی برای این سوال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||||||
|
"webSearch": "جستجوی وب"
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "آزمایشگاه",
|
"title": "آزمایشگاه",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1360,7 +1360,8 @@
|
|||||||
"renameError": "नाम बदलने में त्रुटि",
|
"renameError": "नाम बदलने में त्रुटि",
|
||||||
"welcome": "मैं आपके नोट्स को संश्लेषित करने, नए विचार उत्पन्न करने या आपके नोटबुक पर चर्चा करने में मदद करने के लिए यहां हूं।",
|
"welcome": "मैं आपके नोट्स को संश्लेषित करने, नए विचार उत्पन्न करने या आपके नोटबुक पर चर्चा करने में मदद करने के लिए यहां हूं।",
|
||||||
"searching": "खोज रहे हैं...",
|
"searching": "खोज रहे हैं...",
|
||||||
"noNotesFoundForContext": "इस प्रश्न के लिए कोई प्रासंगिक नोट्स नहीं मिले। अपने सामान्य ज्ञान से उत्तर दें।"
|
"noNotesFoundForContext": "इस प्रश्न के लिए कोई प्रासंगिक नोट्स नहीं मिले। अपने सामान्य ज्ञान से उत्तर दें।",
|
||||||
|
"webSearch": "वेब खोज"
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "लैब",
|
"title": "लैब",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1383,7 +1383,8 @@
|
|||||||
"renameError": "名前変更エラー",
|
"renameError": "名前変更エラー",
|
||||||
"welcome": "ノートの要約、新しいアイデアの生成、ノートブックについてのディスカッションなどをお手伝いします。",
|
"welcome": "ノートの要約、新しいアイデアの生成、ノートブックについてのディスカッションなどをお手伝いします。",
|
||||||
"searching": "検索中...",
|
"searching": "検索中...",
|
||||||
"noNotesFoundForContext": "この質問に関連するノートが見つかりませんでした。一般的な知識でお答えください。"
|
"noNotesFoundForContext": "この質問に関連するノートが見つかりませんでした。一般的な知識でお答えください。",
|
||||||
|
"webSearch": "ウェブ検索"
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "ラボ",
|
"title": "ラボ",
|
||||||
|
|||||||
@@ -1360,7 +1360,8 @@
|
|||||||
"renameError": "이름 변경 중 오류",
|
"renameError": "이름 변경 중 오류",
|
||||||
"welcome": "노트를 종합하고, 새로운 아이디어를 생성하거나, 노트북에 대해 논의하는 데 도움을 드릴 수 있습니다.",
|
"welcome": "노트를 종합하고, 새로운 아이디어를 생성하거나, 노트북에 대해 논의하는 데 도움을 드릴 수 있습니다.",
|
||||||
"searching": "검색 중...",
|
"searching": "검색 중...",
|
||||||
"noNotesFoundForContext": "이 질문에 대한 관련 노트를 찾을 수 없습니다. 일반 지식으로 답변하세요."
|
"noNotesFoundForContext": "이 질문에 대한 관련 노트를 찾을 수 없습니다. 일반 지식으로 답변하세요.",
|
||||||
|
"webSearch": "웹 검색"
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "랩",
|
"title": "랩",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1355,7 +1355,8 @@
|
|||||||
"renameError": "Ошибка переименования",
|
"renameError": "Ошибка переименования",
|
||||||
"welcome": "Я здесь, чтобы помочь вам обобщить заметки, генерировать новые идеи или обсуждать ваши блокноты.",
|
"welcome": "Я здесь, чтобы помочь вам обобщить заметки, генерировать новые идеи или обсуждать ваши блокноты.",
|
||||||
"searching": "Поиск...",
|
"searching": "Поиск...",
|
||||||
"noNotesFoundForContext": "Не найдено заметок по этому вопросу. Ответьте, используя свои общие знания."
|
"noNotesFoundForContext": "Не найдено заметок по этому вопросу. Ответьте, используя свои общие знания.",
|
||||||
|
"webSearch": "Веб-поиск"
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "Лаборатория",
|
"title": "Лаборатория",
|
||||||
|
|||||||
@@ -1383,7 +1383,8 @@
|
|||||||
"renameError": "重命名时出错",
|
"renameError": "重命名时出错",
|
||||||
"welcome": "我可以帮助您综合笔记、生成新想法或讨论您的笔记本。",
|
"welcome": "我可以帮助您综合笔记、生成新想法或讨论您的笔记本。",
|
||||||
"searching": "搜索中...",
|
"searching": "搜索中...",
|
||||||
"noNotesFoundForContext": "未找到与此问题相关的笔记。请用你的常识回答。"
|
"noNotesFoundForContext": "未找到与此问题相关的笔记。请用你的常识回答。",
|
||||||
|
"webSearch": "网络搜索"
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "实验室",
|
"title": "实验室",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 43 KiB |