@lobehub/lobehub 2.0.0-next.374 → 2.0.0-next.376

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.github/workflows/claude-migration-support.yml +1 -1
  2. package/.github/workflows/revalidate-docs.yml +25 -0
  3. package/CHANGELOG.md +66 -0
  4. package/apps/desktop/src/main/controllers/LocalFileCtr.ts +7 -1
  5. package/apps/desktop/src/main/controllers/ShellCommandCtr.ts +23 -10
  6. package/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts +261 -0
  7. package/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts +36 -0
  8. package/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts +2 -1
  9. package/changelog/v1.json +21 -0
  10. package/docs/development/database-schema.dbml +89 -0
  11. package/docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx +6 -0
  12. package/docs/self-hosting/advanced/auth/nextauth-to-betterauth.zh-CN.mdx +5 -0
  13. package/docs/usage/features/auth.mdx +54 -33
  14. package/docs/usage/features/auth.zh-CN.mdx +56 -33
  15. package/locales/en-US/chat.json +13 -13
  16. package/locales/en-US/common.json +24 -0
  17. package/locales/en-US/setting.json +19 -0
  18. package/locales/zh-CN/authError.json +2 -0
  19. package/locales/zh-CN/chat.json +14 -14
  20. package/locales/zh-CN/common.json +24 -0
  21. package/locales/zh-CN/setting.json +19 -0
  22. package/package.json +2 -1
  23. package/packages/builtin-tool-group-agent-builder/src/ExecutionRuntime/index.ts +31 -9
  24. package/packages/builtin-tool-group-agent-builder/src/client/Inspector/InviteAgent/index.tsx +28 -12
  25. package/packages/builtin-tool-group-agent-builder/src/client/Inspector/RemoveAgent/index.tsx +28 -12
  26. package/packages/builtin-tool-group-agent-builder/src/types.ts +8 -0
  27. package/packages/builtin-tool-local-system/src/client/Render/EditLocalFile/index.tsx +1 -11
  28. package/packages/builtin-tool-local-system/src/client/Render/ListFiles/index.tsx +6 -11
  29. package/packages/context-engine/src/engine/tools/ToolsEngine.ts +16 -10
  30. package/packages/context-engine/src/engine/tools/__tests__/ToolsEngine.test.ts +142 -0
  31. package/packages/context-engine/src/engine/tools/types.ts +6 -0
  32. package/packages/database/migrations/0074_add_fk_indexes_for_cascade_delete.sql +56 -0
  33. package/packages/database/migrations/meta/0074_snapshot.json +10901 -0
  34. package/packages/database/migrations/meta/_journal.json +7 -0
  35. package/packages/database/src/models/message.ts +84 -81
  36. package/packages/database/src/repositories/search/index.ts +330 -570
  37. package/packages/database/src/schemas/agent.ts +4 -0
  38. package/packages/database/src/schemas/chatGroup.ts +2 -0
  39. package/packages/database/src/schemas/file.ts +4 -0
  40. package/packages/database/src/schemas/generation.ts +1 -0
  41. package/packages/database/src/schemas/message.ts +16 -0
  42. package/packages/database/src/schemas/nextauth.ts +15 -8
  43. package/packages/database/src/schemas/oidc.ts +104 -68
  44. package/packages/database/src/schemas/rag.ts +4 -0
  45. package/packages/database/src/schemas/ragEvals.ts +97 -73
  46. package/packages/database/src/schemas/relations.ts +7 -0
  47. package/packages/database/src/schemas/session.ts +1 -0
  48. package/packages/database/src/schemas/topic.ts +8 -1
  49. package/packages/desktop-bridge/src/index.ts +17 -0
  50. package/scripts/_shared/checkDeprecatedAuth.js +26 -0
  51. package/src/app/(backend)/trpc/async/[trpc]/route.ts +2 -0
  52. package/src/app/(backend)/trpc/lambda/[trpc]/route.ts +2 -5
  53. package/src/app/(backend)/trpc/mobile/[trpc]/route.ts +2 -5
  54. package/src/app/(backend)/trpc/tools/[trpc]/route.ts +2 -0
  55. package/src/app/[variants]/(auth)/signup/[[...signup]]/useSignUp.tsx +7 -4
  56. package/src/app/[variants]/(main)/agent/features/Conversation/AgentWelcome/index.tsx +3 -23
  57. package/src/app/[variants]/(main)/group/features/Conversation/AgentWelcome/index.tsx +3 -23
  58. package/src/app/[variants]/(main)/resource/(home)/index.tsx +4 -1
  59. package/src/app/[variants]/(main)/resource/library/_layout/Header/LibraryHead.tsx +3 -0
  60. package/src/app/[variants]/(main)/resource/library/_layout/Header/index.tsx +2 -1
  61. package/src/app/[variants]/(main)/settings/storage/features/Advanced.tsx +19 -12
  62. package/src/app/[variants]/(main)/settings/storage/index.tsx +19 -1
  63. package/src/business/client/features/AccountDeletion/index.tsx +3 -0
  64. package/src/business/server/lambda-routers/accountDeletion.ts +3 -0
  65. package/src/components/DragUpload/index.tsx +1 -1
  66. package/src/config/routes/index.ts +9 -1
  67. package/src/features/CommandMenu/ContextCommands.tsx +10 -2
  68. package/src/features/CommandMenu/MainMenu.tsx +29 -22
  69. package/src/features/CommandMenu/SearchResults.tsx +19 -0
  70. package/src/features/CommandMenu/components/CommandInput.tsx +1 -0
  71. package/src/features/CommandMenu/index.tsx +25 -3
  72. package/src/features/CommandMenu/styles.ts +1 -0
  73. package/src/features/CommandMenu/useCommandMenu.ts +8 -2
  74. package/src/features/CommandMenu/utils/contextCommands.ts +17 -2
  75. package/src/features/CommandMenu/utils/queryParser.ts +1 -0
  76. package/src/features/Conversation/ChatItem/ChatItem.tsx +4 -7
  77. package/src/features/Conversation/ChatItem/style.ts +0 -3
  78. package/src/features/Conversation/ChatItem/type.ts +4 -1
  79. package/src/features/Conversation/Messages/Assistant/index.tsx +6 -2
  80. package/src/features/Conversation/Messages/AssistantGroup/index.tsx +8 -3
  81. package/src/features/Conversation/Messages/Supervisor/index.tsx +6 -2
  82. package/src/features/Conversation/Messages/Task/index.tsx +6 -2
  83. package/src/features/Conversation/Messages/components/useNewScreen.test.ts +331 -0
  84. package/src/features/Conversation/Messages/components/useNewScreen.ts +80 -5
  85. package/src/features/Electron/connection/Connection.tsx +20 -15
  86. package/src/features/FileViewer/index.tsx +28 -5
  87. package/src/features/ModelSwitchPanel/components/List/ListItemRenderer.tsx +10 -9
  88. package/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx +1 -0
  89. package/src/features/ModelSwitchPanel/components/Toolbar.tsx +1 -1
  90. package/src/features/ResourceManager/components/Editor/index.tsx +7 -4
  91. package/src/features/ResourceManager/components/Explorer/Header/index.tsx +1 -1
  92. package/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx +12 -1
  93. package/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx +23 -43
  94. package/src/features/ResourceManager/components/Explorer/MasonryView/MasonryFileItem/ImageFileItem.tsx +1 -0
  95. package/src/features/ResourceManager/components/Explorer/MasonryView/MasonryFileItem/index.tsx +24 -44
  96. package/src/features/ResourceManager/components/Explorer/hooks/useFileItemClick.ts +78 -0
  97. package/src/features/ResourceManager/components/Header/AddButton.tsx +0 -5
  98. package/src/features/ResourceManager/index.tsx +38 -24
  99. package/src/libs/better-auth/define-config.ts +17 -0
  100. package/src/libs/better-auth/plugins/email-whitelist.ts +4 -1
  101. package/src/libs/redis/manager.ts +5 -9
  102. package/src/libs/trpc/utils/responseMeta.test.ts +82 -0
  103. package/src/libs/trpc/utils/responseMeta.ts +41 -0
  104. package/src/locales/default/authError.ts +3 -0
  105. package/src/locales/default/chat.ts +13 -13
  106. package/src/locales/default/common.ts +82 -4
  107. package/src/locales/default/setting.ts +23 -1
  108. package/src/server/modules/AgentRuntime/redis.ts +3 -0
  109. package/src/server/routers/lambda/index.ts +2 -0
  110. package/src/server/routers/lambda/search.ts +3 -2
  111. package/src/services/chat/chat.test.ts +125 -45
  112. package/src/services/chat/index.ts +10 -14
  113. package/src/services/chat/mecha/agentConfigResolver.ts +8 -0
  114. package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +29 -0
  115. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +27 -19
  116. package/src/utils/errorResponse.ts +2 -1
  117. package/src/app/[variants]/(main)/agent/features/Conversation/AgentWelcome/AddButton.tsx +0 -37
  118. package/src/app/[variants]/(main)/group/features/Conversation/AgentWelcome/AddButton.tsx +0 -32
@@ -1,6 +1,14 @@
1
- import { sql } from 'drizzle-orm';
2
-
3
- import { agents, documents, files, knowledgeBaseFiles, messages, topics } from '../../schemas';
1
+ import { and, desc, eq, ilike, ne, or, sql } from 'drizzle-orm';
2
+
3
+ import {
4
+ agents,
5
+ documents,
6
+ files,
7
+ knowledgeBaseFiles,
8
+ messages,
9
+ topics,
10
+ userMemories,
11
+ } from '../../schemas';
4
12
  import { LobeChatDatabase } from '../../type';
5
13
 
6
14
  export type SearchResultType =
@@ -76,6 +84,11 @@ export interface MessageSearchResult extends BaseSearchResult {
76
84
  type: 'message';
77
85
  }
78
86
 
87
+ export interface MemorySearchResult extends BaseSearchResult {
88
+ memoryLayer: string | null;
89
+ type: 'memory';
90
+ }
91
+
79
92
  export interface MCPSearchResult extends BaseSearchResult {
80
93
  author: string;
81
94
  avatar?: string | null;
@@ -115,6 +128,7 @@ export type SearchResult =
115
128
  | FileSearchResult
116
129
  | FolderSearchResult
117
130
  | MessageSearchResult
131
+ | MemorySearchResult
118
132
  | MCPSearchResult
119
133
  | PluginSearchResult
120
134
  | AssistantSearchResult;
@@ -150,61 +164,45 @@ export class SearchRepo {
150
164
  if (!query || query.trim() === '') return [];
151
165
 
152
166
  const trimmedQuery = query.trim();
153
- const searchTerm = `%${trimmedQuery}%`;
154
- const exactQuery = trimmedQuery;
155
- const prefixQuery = `${trimmedQuery}%`;
156
167
 
157
168
  // Context-aware limits: prioritize relevant types based on context
158
169
  const limits = this.calculateLimits(limitPerType, type, agentId, contextType);
159
170
 
160
- // Build queries based on type filter
161
- const queries = [];
171
+ // Run searches in parallel for better performance
172
+ const searchPromises: Promise<SearchResult[]>[] = [];
173
+
162
174
  if ((!type || type === 'agent') && limits.agent > 0) {
163
- queries.push(this.buildAgentQuery(searchTerm, exactQuery, prefixQuery, limits.agent));
175
+ searchPromises.push(this.searchAgents(trimmedQuery, limits.agent));
164
176
  }
165
177
  if ((!type || type === 'topic') && limits.topic > 0) {
166
- queries.push(
167
- this.buildTopicQuery(searchTerm, exactQuery, prefixQuery, limits.topic, agentId),
168
- );
178
+ searchPromises.push(this.searchTopics(trimmedQuery, limits.topic, agentId));
169
179
  }
170
180
  if ((!type || type === 'message') && limits.message > 0) {
171
- queries.push(
172
- this.buildMessageQuery(searchTerm, exactQuery, prefixQuery, limits.message, agentId),
173
- );
181
+ searchPromises.push(this.searchMessages(trimmedQuery, limits.message, agentId));
174
182
  }
175
183
  if ((!type || type === 'file') && limits.file > 0) {
176
- queries.push(this.buildFileQuery(searchTerm, exactQuery, prefixQuery, limits.file));
184
+ searchPromises.push(this.searchFiles(trimmedQuery, limits.file));
177
185
  }
178
186
  if ((!type || type === 'folder') && limits.folder > 0) {
179
- queries.push(this.buildFolderQuery(searchTerm, exactQuery, prefixQuery, limits.folder));
187
+ searchPromises.push(this.searchFolders(trimmedQuery, limits.folder));
180
188
  }
181
189
  if ((!type || type === 'page') && limits.page > 0) {
182
- queries.push(this.buildPageQuery(searchTerm, exactQuery, prefixQuery, limits.page));
190
+ searchPromises.push(this.searchPages(trimmedQuery, limits.page));
191
+ }
192
+ if ((!type || type === 'memory') && limits.memory > 0) {
193
+ searchPromises.push(this.searchMemories(trimmedQuery, limits.memory));
183
194
  }
184
195
 
185
- if (queries.length === 0) return [];
186
-
187
- // Combine with UNION ALL (pattern from KnowledgeRepo)
188
- const unionQuery = sql.join(
189
- queries.map((q) => sql`(${q})`),
190
- sql` UNION ALL `,
191
- );
192
-
193
- const finalQuery = sql`
194
- SELECT * FROM (${unionQuery}) as combined
195
- ORDER BY relevance ASC, updated_at DESC
196
- `;
196
+ const results = await Promise.all(searchPromises);
197
197
 
198
- const result = await this.db.execute(finalQuery);
199
- return this.mapResults(result.rows as any[]);
198
+ // Flatten and sort by relevance ASC, then by updatedAt DESC
199
+ return results
200
+ .flat()
201
+ .sort((a, b) => a.relevance - b.relevance || b.updatedAt.getTime() - a.updatedAt.getTime());
200
202
  }
201
203
 
202
204
  /**
203
205
  * Calculate result limits based on context
204
- * - Agent context: expand topics (6) and messages (6), limit others (3 each)
205
- * - Page context: expand pages (6), limit others (3 each)
206
- * - Resource context: expand files (6) and folders (6), limit others (3 each)
207
- * - General context: limit all types to 3 each
208
206
  */
209
207
  private calculateLimits(
210
208
  baseLimit: number,
@@ -215,6 +213,7 @@ export class SearchRepo {
215
213
  agent: number;
216
214
  file: number;
217
215
  folder: number;
216
+ memory: number;
218
217
  message: number;
219
218
  page: number;
220
219
  pageContent: number;
@@ -226,6 +225,7 @@ export class SearchRepo {
226
225
  agent: type === 'agent' ? baseLimit : 0,
227
226
  file: type === 'file' ? baseLimit : 0,
228
227
  folder: type === 'folder' ? baseLimit : 0,
228
+ memory: type === 'memory' ? baseLimit : 0,
229
229
  message: type === 'message' ? baseLimit : 0,
230
230
  page: type === 'page' ? baseLimit : 0,
231
231
  pageContent: type === 'pageContent' ? baseLimit : 0,
@@ -239,9 +239,10 @@ export class SearchRepo {
239
239
  agent: 3,
240
240
  file: 3,
241
241
  folder: 3,
242
+ memory: 3,
242
243
  message: 3,
243
244
  page: 6,
244
- pageContent: 0, // Not available yet
245
+ pageContent: 0,
245
246
  topic: 3,
246
247
  };
247
248
  }
@@ -252,9 +253,10 @@ export class SearchRepo {
252
253
  agent: 3,
253
254
  file: 6,
254
255
  folder: 6,
256
+ memory: 3,
255
257
  message: 3,
256
258
  page: 3,
257
- pageContent: 0, // Not available yet
259
+ pageContent: 0,
258
260
  topic: 3,
259
261
  };
260
262
  }
@@ -265,9 +267,10 @@ export class SearchRepo {
265
267
  agent: 3,
266
268
  file: 3,
267
269
  folder: 3,
270
+ memory: 3,
268
271
  message: 6,
269
272
  page: 3,
270
- pageContent: 0, // Not available yet
273
+ pageContent: 0,
271
274
  topic: 6,
272
275
  };
273
276
  }
@@ -277,581 +280,338 @@ export class SearchRepo {
277
280
  agent: 3,
278
281
  file: 3,
279
282
  folder: 3,
283
+ memory: 3,
280
284
  message: 3,
281
285
  page: 3,
282
- pageContent: 0, // Not available yet
286
+ pageContent: 0,
283
287
  topic: 3,
284
288
  };
285
289
  }
286
290
 
287
291
  /**
288
- * Truncate content at database level with ellipsis indicator
289
- * Uses SQL LEFT() function for efficient truncation
290
- * Note: This helper is defined for documentation but not currently used.
291
- * Truncation is implemented inline in query methods for better SQL readability.
292
+ * Calculate relevance score: 1=exact, 2=prefix, 3=contains
292
293
  */
293
- private truncateContent(columnName: string, maxLength: number): string {
294
- return `CASE
295
- WHEN LENGTH(${columnName}) > ${maxLength}
296
- THEN LEFT(${columnName}, ${maxLength}) || '...'
297
- ELSE ${columnName}
298
- END`;
294
+ private calculateRelevance(value: string | null | undefined, query: string): number {
295
+ if (!value) return 3;
296
+ const lower = value.toLowerCase();
297
+ const queryLower = query.toLowerCase();
298
+ if (lower === queryLower) return 1;
299
+ if (lower.startsWith(queryLower)) return 2;
300
+ return 3;
299
301
  }
300
302
 
301
303
  /**
302
- * Build agent search query
303
- * Searches: title, description, slug, tags (JSONB array)
304
+ * Truncate content with ellipsis
304
305
  */
305
- private buildAgentQuery(
306
- searchTerm: string,
307
- exactQuery: string,
308
- prefixQuery: string,
309
- limit: number,
310
- ): ReturnType<typeof sql> {
311
- return sql`
312
- SELECT
313
- a.id,
314
- 'agent' as type,
315
- a.title,
316
- a.description,
317
- a.slug,
318
- a.avatar,
319
- a.background_color,
320
- a.tags,
321
- a.created_at,
322
- a.updated_at,
323
- CASE
324
- WHEN a.title ILIKE ${exactQuery} THEN 1
325
- WHEN a.title ILIKE ${prefixQuery} THEN 2
326
- ELSE 3
327
- END as relevance,
328
- NULL::boolean as favorite,
329
- NULL::text as session_id,
330
- NULL::text as agent_id,
331
- NULL::text as name,
332
- NULL::varchar(255) as file_type,
333
- NULL::integer as size,
334
- NULL::text as url,
335
- NULL::text as knowledge_base_id
336
- FROM ${agents} a
337
- WHERE a.user_id = ${this.userId}
338
- AND (
339
- a.title ILIKE ${searchTerm}
340
- OR COALESCE(a.description, '') ILIKE ${searchTerm}
341
- OR COALESCE(a.slug, '') ILIKE ${searchTerm}
342
- OR (
343
- a.tags IS NOT NULL
344
- AND EXISTS (
345
- SELECT 1 FROM jsonb_array_elements_text(a.tags) AS tag
306
+ private truncate(content: string | null | undefined, maxLength: number = 200): string | null {
307
+ if (!content) return null;
308
+ if (content.length <= maxLength) return content;
309
+ return content.slice(0, maxLength) + '...';
310
+ }
311
+
312
+ /**
313
+ * Search agents by title, description, slug, tags
314
+ */
315
+ private async searchAgents(query: string, limit: number): Promise<AgentSearchResult[]> {
316
+ const searchTerm = `%${query}%`;
317
+
318
+ const rows = await this.db
319
+ .select()
320
+ .from(agents)
321
+ .where(
322
+ and(
323
+ eq(agents.userId, this.userId),
324
+ or(
325
+ ilike(agents.title, searchTerm),
326
+ ilike(sql`COALESCE(${agents.description}, '')`, searchTerm),
327
+ ilike(sql`COALESCE(${agents.slug}, '')`, searchTerm),
328
+ sql`${agents.tags} IS NOT NULL AND EXISTS (
329
+ SELECT 1 FROM jsonb_array_elements_text(${agents.tags}) AS tag
346
330
  WHERE tag ILIKE ${searchTerm}
347
- )
348
- )
349
- )
350
- LIMIT ${limit}
351
- `;
331
+ )`,
332
+ ),
333
+ ),
334
+ )
335
+ .orderBy(desc(agents.updatedAt))
336
+ .limit(limit);
337
+
338
+ return rows.map((row) => ({
339
+ avatar: row.avatar,
340
+ backgroundColor: row.backgroundColor,
341
+ createdAt: row.createdAt,
342
+ description: row.description,
343
+ id: row.id,
344
+ relevance: this.calculateRelevance(row.title, query),
345
+ slug: row.slug,
346
+ tags: (row.tags as string[]) || [],
347
+ title: row.title || '',
348
+ type: 'agent' as const,
349
+ updatedAt: row.updatedAt,
350
+ }));
352
351
  }
353
352
 
354
353
  /**
355
- * Build topic search query with optional agent-context boosting
356
- * Searches: title, content, historySummary
357
- * When agentId is provided:
358
- * - Current agent's topics: relevance 0.5-0.7 (highest priority)
359
- * - Other topics: relevance 1-3 (normal priority)
354
+ * Search topics by title, content, historySummary
360
355
  */
361
- private buildTopicQuery(
362
- searchTerm: string,
363
- exactQuery: string,
364
- prefixQuery: string,
356
+ private async searchTopics(
357
+ query: string,
365
358
  limit: number,
366
359
  agentId?: string,
367
- ): ReturnType<typeof sql> {
368
- // Build relevance CASE statement with agent boosting
369
- const relevanceCase = agentId
370
- ? sql`
371
- CASE
372
- WHEN t.agent_id = ${agentId} THEN
373
- CASE
374
- WHEN t.title ILIKE ${exactQuery} THEN 0.5
375
- WHEN t.title ILIKE ${prefixQuery} THEN 0.6
376
- ELSE 0.7
377
- END
378
- WHEN t.title ILIKE ${exactQuery} THEN 1
379
- WHEN t.title ILIKE ${prefixQuery} THEN 2
380
- ELSE 3
381
- END
382
- `
383
- : sql`
384
- CASE
385
- WHEN t.title ILIKE ${exactQuery} THEN 1
386
- WHEN t.title ILIKE ${prefixQuery} THEN 2
387
- ELSE 3
388
- END
389
- `;
390
-
391
- return sql`
392
- SELECT
393
- t.id,
394
- 'topic' as type,
395
- t.title,
396
- CASE
397
- WHEN length(COALESCE(t.content, '')) > 200 THEN substring(COALESCE(t.content, ''), 1, 200) || '...'
398
- ELSE t.content
399
- END as description,
400
- NULL::varchar(100) as slug,
401
- NULL::text as avatar,
402
- NULL::text as background_color,
403
- NULL::jsonb as tags,
404
- t.created_at,
405
- t.updated_at,
406
- ${relevanceCase} as relevance,
407
- t.favorite,
408
- t.session_id,
409
- t.agent_id,
410
- NULL::text as name,
411
- NULL::varchar(255) as file_type,
412
- NULL::integer as size,
413
- NULL::text as url,
414
- NULL::text as knowledge_base_id
415
- FROM ${topics} t
416
- WHERE t.user_id = ${this.userId}
417
- AND (
418
- COALESCE(t.title, '') ILIKE ${searchTerm}
419
- OR COALESCE(t.content, '') ILIKE ${searchTerm}
420
- OR COALESCE(t.history_summary, '') ILIKE ${searchTerm}
421
- )
422
- ORDER BY relevance ASC, t.updated_at DESC
423
- LIMIT ${limit}
424
- `;
360
+ ): Promise<TopicSearchResult[]> {
361
+ const searchTerm = `%${query}%`;
362
+
363
+ const rows = await this.db
364
+ .select()
365
+ .from(topics)
366
+ .where(
367
+ and(
368
+ eq(topics.userId, this.userId),
369
+ or(
370
+ ilike(sql`COALESCE(${topics.title}, '')`, searchTerm),
371
+ ilike(sql`COALESCE(${topics.content}, '')`, searchTerm),
372
+ ilike(sql`COALESCE(${topics.historySummary}, '')`, searchTerm),
373
+ ),
374
+ ),
375
+ )
376
+ .orderBy(desc(topics.updatedAt))
377
+ .limit(limit);
378
+
379
+ return rows.map((row) => {
380
+ // Agent context boosting: current agent's topics get higher priority
381
+ let relevance = this.calculateRelevance(row.title, query);
382
+ if (agentId && row.agentId === agentId) {
383
+ // Boost current agent's topics (0.5-0.7 range)
384
+ relevance = relevance === 1 ? 0.5 : relevance === 2 ? 0.6 : 0.7;
385
+ }
386
+
387
+ return {
388
+ agentId: row.agentId,
389
+ createdAt: row.createdAt,
390
+ description: this.truncate(row.content),
391
+ favorite: row.favorite,
392
+ id: row.id,
393
+ relevance,
394
+ sessionId: row.sessionId,
395
+ title: row.title || '',
396
+ type: 'topic' as const,
397
+ updatedAt: row.updatedAt,
398
+ };
399
+ });
425
400
  }
426
401
 
427
402
  /**
428
- * Build message search query with optional agent-context boosting
429
- * Searches: message content (supports multi-word queries)
430
- * When agentId is provided:
431
- * - Current agent's messages: relevance 0.5-0.7 (highest priority)
432
- * - Other messages: relevance 1-3 (normal priority)
403
+ * Search messages by content
433
404
  */
434
- private buildMessageQuery(
435
- searchTerm: string,
436
- exactQuery: string,
437
- prefixQuery: string,
405
+ private async searchMessages(
406
+ query: string,
438
407
  limit: number,
439
408
  agentId?: string,
440
- ): ReturnType<typeof sql> {
441
- // Split search query into words for better multi-word search
442
- const words = exactQuery
443
- .trim()
444
- .split(/\s+/)
445
- .filter((w) => w.length > 0);
446
-
447
- // Build WHERE clause: search for any of the words
409
+ ): Promise<MessageSearchResult[]> {
410
+ const searchTerm = `%${query}%`;
411
+
412
+ // Split query into words for multi-word search
413
+ const words = query.split(/\s+/).filter((w) => w.length > 0);
414
+
448
415
  const wordConditions =
449
416
  words.length > 1
450
- ? sql.join(
451
- words.map((word) => sql`COALESCE(m.content, '') ILIKE ${`%${word}%`}`),
452
- sql` OR `,
453
- )
454
- : sql`COALESCE(m.content, '') ILIKE ${searchTerm}`;
455
-
456
- // Build relevance CASE statement with agent boosting
457
- const relevanceCase = agentId
458
- ? sql`
459
- CASE
460
- WHEN m.agent_id = ${agentId} THEN
461
- CASE
462
- WHEN m.content ILIKE ${exactQuery} THEN 0.5
463
- WHEN m.content ILIKE ${prefixQuery} THEN 0.6
464
- ELSE 0.7
465
- END
466
- WHEN m.content ILIKE ${exactQuery} THEN 1
467
- WHEN m.content ILIKE ${prefixQuery} THEN 2
468
- ELSE 3
469
- END
470
- `
471
- : sql`
472
- CASE
473
- WHEN m.content ILIKE ${exactQuery} THEN 1
474
- WHEN m.content ILIKE ${prefixQuery} THEN 2
475
- ELSE 3
476
- END
477
- `;
478
-
479
- return sql`
480
- SELECT
481
- m.id,
482
- 'message' as type,
483
- CASE
484
- WHEN length(m.content) > 200 THEN substring(m.content, 1, 200) || '...'
485
- ELSE m.content
486
- END as title,
487
- COALESCE(a.title, 'General Chat') as description,
488
- m.model as slug,
489
- NULL::text as avatar,
490
- NULL::text as background_color,
491
- NULL::jsonb as tags,
492
- m.created_at,
493
- m.updated_at,
494
- ${relevanceCase} as relevance,
495
- NULL::boolean as favorite,
496
- m.topic_id as session_id,
497
- m.agent_id,
498
- m.role as name,
499
- NULL::varchar(255) as file_type,
500
- NULL::integer as size,
501
- NULL::text as url,
502
- NULL::text as knowledge_base_id
503
- FROM ${messages} m
504
- LEFT JOIN ${agents} a ON m.agent_id = a.id
505
- WHERE m.user_id = ${this.userId}
506
- AND m.role != 'tool'
507
- AND (${wordConditions})
508
- ORDER BY relevance ASC, m.created_at DESC
509
- LIMIT ${limit}
510
- `;
511
- }
417
+ ? or(...words.map((word) => ilike(sql`COALESCE(${messages.content}, '')`, `%${word}%`)))
418
+ : ilike(sql`COALESCE(${messages.content}, '')`, searchTerm);
419
+
420
+ const rows = await this.db
421
+ .select({
422
+ agentId: messages.agentId,
423
+ agentTitle: agents.title,
424
+ content: messages.content,
425
+ createdAt: messages.createdAt,
426
+ id: messages.id,
427
+ model: messages.model,
428
+ role: messages.role,
429
+ topicId: messages.topicId,
430
+ updatedAt: messages.updatedAt,
431
+ })
432
+ .from(messages)
433
+ .leftJoin(agents, eq(messages.agentId, agents.id))
434
+ .where(and(eq(messages.userId, this.userId), ne(messages.role, 'tool'), wordConditions))
435
+ .orderBy(desc(messages.createdAt))
436
+ .limit(limit);
512
437
 
513
- /**
514
- * Build file search query
515
- * Searches files and their linked documents, excluding pages (file_type='custom/document')
516
- */
517
- private buildFileQuery(
518
- searchTerm: string,
519
- exactQuery: string,
520
- prefixQuery: string,
521
- limit: number,
522
- ): ReturnType<typeof sql> {
523
- // Query for files (with optional linked documents), excluding custom/document files
524
- const fileQuery = sql`
525
- SELECT
526
- f.id,
527
- 'file' as type,
528
- f.name as title,
529
- CASE
530
- WHEN length(COALESCE(d.content, '')) > 200 THEN substring(COALESCE(d.content, ''), 1, 200) || '...'
531
- ELSE d.content
532
- END as description,
533
- NULL::varchar(100) as slug,
534
- NULL::text as avatar,
535
- NULL::text as background_color,
536
- NULL::jsonb as tags,
537
- f.created_at,
538
- f.updated_at,
539
- CASE
540
- WHEN f.name ILIKE ${exactQuery} THEN 1
541
- WHEN f.name ILIKE ${prefixQuery} THEN 2
542
- ELSE 3
543
- END as relevance,
544
- NULL::boolean as favorite,
545
- NULL::text as session_id,
546
- NULL::text as agent_id,
547
- f.name,
548
- f.file_type,
549
- f.size,
550
- f.url,
551
- kbf.knowledge_base_id
552
- FROM ${files} f
553
- LEFT JOIN ${documents} d ON f.id = d.file_id
554
- LEFT JOIN ${knowledgeBaseFiles} kbf ON f.id = kbf.file_id
555
- WHERE f.user_id = ${this.userId}
556
- AND f.file_type != 'custom/document'
557
- AND f.name ILIKE ${searchTerm}
558
- `;
559
-
560
- // Query for standalone documents (not pages, not folders, and not linked to files)
561
- const documentQuery = sql`
562
- SELECT
563
- d.id,
564
- 'file' as type,
565
- COALESCE(d.title, d.filename, 'Untitled') as title,
566
- CASE
567
- WHEN length(COALESCE(d.content, '')) > 200 THEN substring(COALESCE(d.content, ''), 1, 200) || '...'
568
- ELSE d.content
569
- END as description,
570
- NULL::varchar(100) as slug,
571
- NULL::text as avatar,
572
- NULL::text as background_color,
573
- NULL::jsonb as tags,
574
- d.created_at,
575
- d.updated_at,
576
- CASE
577
- WHEN COALESCE(d.title, d.filename) ILIKE ${exactQuery} THEN 1
578
- WHEN COALESCE(d.title, d.filename) ILIKE ${prefixQuery} THEN 2
579
- ELSE 3
580
- END as relevance,
581
- NULL::boolean as favorite,
582
- NULL::text as session_id,
583
- NULL::text as agent_id,
584
- COALESCE(d.title, d.filename, 'Untitled') as name,
585
- d.file_type,
586
- d.total_char_count as size,
587
- d.source as url,
588
- kbf.knowledge_base_id
589
- FROM ${documents} d
590
- LEFT JOIN ${files} f ON d.file_id = f.id
591
- LEFT JOIN ${knowledgeBaseFiles} kbf ON f.id = kbf.file_id
592
- WHERE d.user_id = ${this.userId}
593
- AND d.source_type != 'file'
594
- AND d.file_type != 'custom/document'
595
- AND d.file_type != 'custom/folder'
596
- AND (
597
- COALESCE(d.title, '') ILIKE ${searchTerm}
598
- OR COALESCE(d.filename, '') ILIKE ${searchTerm}
599
- OR COALESCE(d.content, '') ILIKE ${searchTerm}
600
- )
601
- `;
602
-
603
- // Combine both queries
604
- return sql`
605
- SELECT * FROM (
606
- (${fileQuery})
607
- UNION ALL
608
- (${documentQuery})
609
- ) as combined
610
- ORDER BY relevance ASC, updated_at DESC
611
- LIMIT ${limit}
612
- `;
613
- }
438
+ return rows.map((row) => {
439
+ // Agent context boosting
440
+ let relevance = this.calculateRelevance(row.content, query);
441
+ if (agentId && row.agentId === agentId) {
442
+ relevance = relevance === 1 ? 0.5 : relevance === 2 ? 0.6 : 0.7;
443
+ }
614
444
 
615
- /**
616
- * Build folder search query
617
- * Searches folders in the documents table (file_type='custom/folder')
618
- */
619
- private buildFolderQuery(
620
- searchTerm: string,
621
- exactQuery: string,
622
- prefixQuery: string,
623
- limit: number,
624
- ): ReturnType<typeof sql> {
625
- return sql`
626
- SELECT
627
- d.id,
628
- 'folder' as type,
629
- COALESCE(d.title, d.filename, 'Untitled') as title,
630
- d.description,
631
- d.slug,
632
- NULL::text as avatar,
633
- NULL::text as background_color,
634
- NULL::jsonb as tags,
635
- d.created_at,
636
- d.updated_at,
637
- CASE
638
- WHEN COALESCE(d.title, d.filename) ILIKE ${exactQuery} THEN 1
639
- WHEN COALESCE(d.title, d.filename) ILIKE ${prefixQuery} THEN 2
640
- ELSE 3
641
- END as relevance,
642
- NULL::boolean as favorite,
643
- NULL::text as session_id,
644
- NULL::text as agent_id,
645
- COALESCE(d.title, d.filename, 'Untitled') as name,
646
- d.file_type,
647
- NULL::integer as size,
648
- NULL::text as url,
649
- d.knowledge_base_id
650
- FROM ${documents} d
651
- WHERE d.user_id = ${this.userId}
652
- AND d.file_type = 'custom/folder'
653
- AND (
654
- COALESCE(d.title, '') ILIKE ${searchTerm}
655
- OR COALESCE(d.filename, '') ILIKE ${searchTerm}
656
- OR COALESCE(d.description, '') ILIKE ${searchTerm}
657
- )
658
- ORDER BY relevance ASC, updated_at DESC
659
- LIMIT ${limit}
660
- `;
445
+ return {
446
+ agentId: row.agentId,
447
+ content: row.content || '',
448
+ createdAt: row.createdAt,
449
+ description: row.agentTitle || 'General Chat',
450
+ id: row.id,
451
+ model: row.model,
452
+ relevance,
453
+ role: row.role,
454
+ title: this.truncate(row.content) || '',
455
+ topicId: row.topicId,
456
+ type: 'message' as const,
457
+ updatedAt: row.updatedAt,
458
+ };
459
+ });
661
460
  }
662
461
 
663
462
  /**
664
- * Build page search query
665
- * Fast search on page titles only (no content search for better performance)
666
- * Searches standalone documents with type='custom/document'
463
+ * Search files by name
667
464
  */
668
- private buildPageQuery(
669
- searchTerm: string,
670
- exactQuery: string,
671
- prefixQuery: string,
672
- limit: number,
673
- ): ReturnType<typeof sql> {
674
- return sql`
675
- SELECT
676
- d.id,
677
- 'page' as type,
678
- COALESCE(d.title, d.filename, 'Untitled') as title,
679
- NULL::text as description,
680
- NULL::varchar(100) as slug,
681
- NULL::text as avatar,
682
- NULL::text as background_color,
683
- NULL::jsonb as tags,
684
- d.created_at,
685
- d.updated_at,
686
- CASE
687
- WHEN COALESCE(d.title, d.filename) ILIKE ${exactQuery} THEN 1
688
- WHEN COALESCE(d.title, d.filename) ILIKE ${prefixQuery} THEN 2
689
- ELSE 3
690
- END as relevance,
691
- NULL::boolean as favorite,
692
- NULL::text as session_id,
693
- NULL::text as agent_id,
694
- COALESCE(d.title, d.filename, 'Untitled') as name,
695
- d.file_type,
696
- d.total_char_count as size,
697
- d.source as url,
698
- NULL::text as knowledge_base_id
699
- FROM ${documents} d
700
- WHERE d.user_id = ${this.userId}
701
- AND d.file_type = 'custom/document'
702
- AND (
703
- COALESCE(d.title, '') ILIKE ${searchTerm}
704
- OR COALESCE(d.filename, '') ILIKE ${searchTerm}
705
- )
706
- ORDER BY relevance ASC, updated_at DESC
707
- LIMIT ${limit}
708
- `;
465
+ private async searchFiles(query: string, limit: number): Promise<FileSearchResult[]> {
466
+ const searchTerm = `%${query}%`;
467
+
468
+ const rows = await this.db
469
+ .select({
470
+ content: documents.content,
471
+ createdAt: files.createdAt,
472
+ fileType: files.fileType,
473
+ id: files.id,
474
+ knowledgeBaseId: knowledgeBaseFiles.knowledgeBaseId,
475
+ name: files.name,
476
+ size: files.size,
477
+ updatedAt: files.updatedAt,
478
+ url: files.url,
479
+ })
480
+ .from(files)
481
+ .leftJoin(documents, eq(files.id, documents.fileId))
482
+ .leftJoin(knowledgeBaseFiles, eq(files.id, knowledgeBaseFiles.fileId))
483
+ .where(
484
+ and(
485
+ eq(files.userId, this.userId),
486
+ ne(files.fileType, 'custom/document'),
487
+ ilike(files.name, searchTerm),
488
+ ),
489
+ )
490
+ .orderBy(desc(files.updatedAt))
491
+ .limit(limit);
492
+
493
+ return rows.map((row) => ({
494
+ createdAt: row.createdAt,
495
+ description: this.truncate(row.content),
496
+ fileType: row.fileType,
497
+ id: row.id,
498
+ knowledgeBaseId: row.knowledgeBaseId,
499
+ name: row.name,
500
+ relevance: this.calculateRelevance(row.name, query),
501
+ size: row.size,
502
+ title: row.name,
503
+ type: 'file' as const,
504
+ updatedAt: row.updatedAt,
505
+ url: row.url,
506
+ }));
709
507
  }
710
508
 
711
509
  /**
712
- * Build page content search query (FUTURE USE - Not integrated yet)
713
- * Full-text search within page content for deep document search
714
- * This is more expensive but allows searching within document body
510
+ * Search folders (documents with file_type='custom/folder')
715
511
  */
716
- private buildPageContentQuery(
717
- searchTerm: string,
718
- exactQuery: string,
719
- prefixQuery: string,
720
- limit: number,
721
- ): ReturnType<typeof sql> {
722
- return sql`
723
- SELECT
724
- d.id,
725
- 'pageContent' as type,
726
- COALESCE(d.title, d.filename, 'Untitled') as title,
727
- CASE
728
- WHEN length(COALESCE(d.content, '')) > 200 THEN substring(COALESCE(d.content, ''), 1, 200) || '...'
729
- ELSE d.content
730
- END as description,
731
- NULL::varchar(100) as slug,
732
- NULL::text as avatar,
733
- NULL::text as background_color,
734
- NULL::jsonb as tags,
735
- d.created_at,
736
- d.updated_at,
737
- CASE
738
- WHEN COALESCE(d.content, '') ILIKE ${exactQuery} THEN 1
739
- WHEN COALESCE(d.content, '') ILIKE ${prefixQuery} THEN 2
740
- ELSE 3
741
- END as relevance,
742
- NULL::boolean as favorite,
743
- NULL::text as session_id,
744
- NULL::text as agent_id,
745
- COALESCE(d.title, d.filename, 'Untitled') as name,
746
- d.file_type,
747
- d.total_char_count as size,
748
- d.source as url,
749
- NULL::text as knowledge_base_id
750
- FROM ${documents} d
751
- WHERE d.user_id = ${this.userId}
752
- AND d.file_type = 'custom/document'
753
- AND COALESCE(d.content, '') ILIKE ${searchTerm}
754
- ORDER BY relevance ASC, updated_at DESC
755
- LIMIT ${limit}
756
- `;
512
+ private async searchFolders(query: string, limit: number): Promise<FolderSearchResult[]> {
513
+ const searchTerm = `%${query}%`;
514
+
515
+ const rows = await this.db
516
+ .select()
517
+ .from(documents)
518
+ .where(
519
+ and(
520
+ eq(documents.userId, this.userId),
521
+ eq(documents.fileType, 'custom/folder'),
522
+ or(
523
+ ilike(sql`COALESCE(${documents.title}, '')`, searchTerm),
524
+ ilike(sql`COALESCE(${documents.filename}, '')`, searchTerm),
525
+ ilike(sql`COALESCE(${documents.description}, '')`, searchTerm),
526
+ ),
527
+ ),
528
+ )
529
+ .orderBy(desc(documents.updatedAt))
530
+ .limit(limit);
531
+
532
+ return rows.map((row) => {
533
+ const title = row.title || row.filename || 'Untitled';
534
+ return {
535
+ createdAt: row.createdAt,
536
+ description: row.description,
537
+ id: row.id,
538
+ knowledgeBaseId: row.knowledgeBaseId,
539
+ relevance: this.calculateRelevance(title, query),
540
+ slug: row.slug,
541
+ title,
542
+ type: 'folder' as const,
543
+ updatedAt: row.updatedAt,
544
+ };
545
+ });
757
546
  }
758
547
 
759
548
  /**
760
- * Map raw SQL results to typed SearchResult objects
761
- * Parse JSONB strings and convert snake_case to camelCase
549
+ * Search pages (documents with file_type='custom/document')
762
550
  */
763
- private mapResults(rows: any[]): SearchResult[] {
551
+ private async searchPages(query: string, limit: number): Promise<PageSearchResult[]> {
552
+ const searchTerm = `%${query}%`;
553
+
554
+ const rows = await this.db
555
+ .select()
556
+ .from(documents)
557
+ .where(
558
+ and(
559
+ eq(documents.userId, this.userId),
560
+ eq(documents.fileType, 'custom/document'),
561
+ or(
562
+ ilike(sql`COALESCE(${documents.title}, '')`, searchTerm),
563
+ ilike(sql`COALESCE(${documents.filename}, '')`, searchTerm),
564
+ ),
565
+ ),
566
+ )
567
+ .orderBy(desc(documents.updatedAt))
568
+ .limit(limit);
569
+
764
570
  return rows.map((row) => {
765
- const base = {
766
- createdAt: new Date(row.created_at),
767
- description: row.description,
571
+ const title = row.title || row.filename || 'Untitled';
572
+ return {
573
+ createdAt: row.createdAt,
574
+ description: null,
768
575
  id: row.id,
769
- relevance: Number(row.relevance),
770
- title: row.title,
771
- type: row.type as SearchResultType,
772
- updatedAt: new Date(row.updated_at),
576
+ relevance: this.calculateRelevance(title, query),
577
+ title,
578
+ type: 'page' as const,
579
+ updatedAt: row.updatedAt,
773
580
  };
774
-
775
- switch (row.type) {
776
- case 'page': {
777
- return {
778
- ...base,
779
- type: 'page' as const,
780
- };
781
- }
782
- case 'pageContent': {
783
- return {
784
- ...base,
785
- type: 'pageContent' as const,
786
- };
787
- }
788
- case 'agent': {
789
- // Parse tags JSONB if string
790
- let tags: string[] = [];
791
- if (row.tags) {
792
- if (typeof row.tags === 'string') {
793
- try {
794
- tags = JSON.parse(row.tags);
795
- } catch {
796
- tags = [];
797
- }
798
- } else {
799
- tags = row.tags;
800
- }
801
- }
802
-
803
- return {
804
- ...base,
805
- avatar: row.avatar,
806
- backgroundColor: row.background_color,
807
- slug: row.slug,
808
- tags,
809
- type: 'agent' as const,
810
- };
811
- }
812
- case 'topic': {
813
- return {
814
- ...base,
815
- agentId: row.agent_id,
816
- favorite: row.favorite,
817
- sessionId: row.session_id,
818
- type: 'topic' as const,
819
- };
820
- }
821
- case 'file': {
822
- return {
823
- ...base,
824
- fileType: row.file_type,
825
- knowledgeBaseId: row.knowledge_base_id,
826
- name: row.name,
827
- size: Number(row.size),
828
- type: 'file' as const,
829
- url: row.url,
830
- };
831
- }
832
- case 'folder': {
833
- return {
834
- ...base,
835
- knowledgeBaseId: row.knowledge_base_id,
836
- slug: row.slug,
837
- type: 'folder' as const,
838
- };
839
- }
840
- case 'message': {
841
- return {
842
- ...base,
843
- agentId: row.agent_id,
844
- content: row.description || '',
845
- model: row.slug,
846
- role: row.name || 'user',
847
- topicId: row.session_id,
848
- type: 'message' as const,
849
- };
850
- }
851
- default: {
852
- throw new Error(`Unknown search result type: ${row.type}`);
853
- }
854
- }
855
581
  });
856
582
  }
583
+
584
+ /**
585
+ * Search memories by title, summary, details
586
+ */
587
+ private async searchMemories(query: string, limit: number): Promise<MemorySearchResult[]> {
588
+ const searchTerm = `%${query}%`;
589
+
590
+ const rows = await this.db
591
+ .select()
592
+ .from(userMemories)
593
+ .where(
594
+ and(
595
+ eq(userMemories.userId, this.userId),
596
+ or(
597
+ ilike(sql`COALESCE(${userMemories.title}, '')`, searchTerm),
598
+ ilike(sql`COALESCE(${userMemories.summary}, '')`, searchTerm),
599
+ ilike(sql`COALESCE(${userMemories.details}, '')`, searchTerm),
600
+ ),
601
+ ),
602
+ )
603
+ .orderBy(desc(userMemories.updatedAt))
604
+ .limit(limit);
605
+
606
+ return rows.map((row) => ({
607
+ createdAt: row.createdAt,
608
+ description: this.truncate(row.summary),
609
+ id: row.id,
610
+ memoryLayer: row.memoryLayer,
611
+ relevance: this.calculateRelevance(row.title, query),
612
+ title: row.title || 'Untitled Memory',
613
+ type: 'memory' as const,
614
+ updatedAt: row.updatedAt,
615
+ }));
616
+ }
857
617
  }