@lobehub/lobehub 2.0.0-next.327 → 2.0.0-next.329

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 (83) hide show
  1. package/.env.example +0 -3
  2. package/.env.example.development +0 -3
  3. package/CHANGELOG.md +58 -0
  4. package/Dockerfile +1 -2
  5. package/changelog/v1.json +18 -0
  6. package/docs/self-hosting/advanced/auth.mdx +5 -6
  7. package/docs/self-hosting/advanced/auth.zh-CN.mdx +5 -6
  8. package/docs/self-hosting/environment-variables/auth.mdx +0 -7
  9. package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +0 -7
  10. package/locales/en-US/chat.json +6 -1
  11. package/locales/en-US/discover.json +1 -0
  12. package/locales/zh-CN/chat.json +5 -0
  13. package/locales/zh-CN/discover.json +1 -0
  14. package/package.json +1 -1
  15. package/packages/agent-runtime/src/agents/GeneralChatAgent.ts +24 -0
  16. package/packages/agent-runtime/src/agents/__tests__/GeneralChatAgent.test.ts +210 -0
  17. package/packages/agent-runtime/src/types/instruction.ts +46 -2
  18. package/packages/builtin-tool-gtd/src/const.ts +1 -0
  19. package/packages/builtin-tool-gtd/src/executor/index.ts +38 -21
  20. package/packages/builtin-tool-gtd/src/manifest.ts +15 -0
  21. package/packages/builtin-tool-gtd/src/systemRole.ts +33 -1
  22. package/packages/builtin-tool-gtd/src/types.ts +55 -33
  23. package/packages/builtin-tool-local-system/src/client/Inspector/ReadLocalFile/index.tsx +1 -0
  24. package/packages/builtin-tool-local-system/src/client/Inspector/RunCommand/index.tsx +1 -1
  25. package/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx +1 -1
  26. package/packages/builtin-tool-local-system/src/client/Streaming/WriteFile/index.tsx +5 -1
  27. package/packages/builtin-tool-notebook/src/systemRole.ts +27 -7
  28. package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +13 -1
  29. package/packages/conversation-flow/src/transformation/__tests__/FlatListBuilder.test.ts +40 -0
  30. package/packages/database/src/models/__tests__/messages/message.thread-query.test.ts +134 -1
  31. package/packages/database/src/models/message.ts +8 -1
  32. package/packages/database/src/models/thread.ts +1 -1
  33. package/packages/types/src/message/ui/chat.ts +2 -0
  34. package/packages/types/src/topic/thread.ts +20 -0
  35. package/scripts/prebuild.mts +2 -2
  36. package/src/app/[variants]/(main)/community/(list)/agent/features/List/Item.tsx +1 -0
  37. package/src/components/StreamingMarkdown/index.tsx +10 -43
  38. package/src/envs/__tests__/app.test.ts +81 -0
  39. package/src/envs/app.ts +14 -2
  40. package/src/envs/auth.test.ts +0 -13
  41. package/src/envs/auth.ts +0 -41
  42. package/src/features/Conversation/Messages/AssistantGroup/components/MessageContent.tsx +0 -2
  43. package/src/features/Conversation/Messages/Task/ClientTaskDetail/CompletedState.tsx +108 -0
  44. package/src/features/Conversation/Messages/Task/ClientTaskDetail/InitializingState.tsx +66 -0
  45. package/src/features/Conversation/Messages/Task/ClientTaskDetail/InstructionAccordion.tsx +63 -0
  46. package/src/features/Conversation/Messages/Task/ClientTaskDetail/ProcessingState.tsx +123 -0
  47. package/src/features/Conversation/Messages/Task/ClientTaskDetail/index.tsx +106 -0
  48. package/src/features/Conversation/Messages/Task/TaskDetailPanel/index.tsx +1 -0
  49. package/src/features/Conversation/Messages/Task/index.tsx +11 -6
  50. package/src/features/Conversation/Messages/Tasks/TaskItem/TaskTitle.tsx +3 -2
  51. package/src/features/Conversation/Messages/Tasks/shared/InitializingState.tsx +0 -4
  52. package/src/features/Conversation/Messages/Tasks/shared/utils.ts +22 -1
  53. package/src/features/Conversation/Messages/components/ContentLoading.tsx +1 -1
  54. package/src/features/Conversation/components/Thinking/index.tsx +9 -30
  55. package/src/features/Conversation/store/slices/data/action.ts +2 -3
  56. package/src/features/NavPanel/components/BackButton.tsx +10 -13
  57. package/src/features/NavPanel/components/NavPanelDraggable.tsx +4 -0
  58. package/src/hooks/useAutoScroll.ts +117 -0
  59. package/src/libs/better-auth/auth-client.ts +0 -9
  60. package/src/libs/better-auth/define-config.ts +13 -12
  61. package/src/libs/better-auth/sso/index.ts +2 -1
  62. package/src/libs/better-auth/utils/config.ts +2 -2
  63. package/src/libs/next/proxy/define-config.ts +4 -6
  64. package/src/locales/default/chat.ts +6 -1
  65. package/src/locales/default/discover.ts +2 -0
  66. package/src/server/routers/lambda/__tests__/integration/topic.integration.test.ts +74 -0
  67. package/src/server/routers/lambda/aiAgent.ts +239 -1
  68. package/src/server/routers/lambda/thread.ts +2 -0
  69. package/src/server/routers/lambda/topic.ts +6 -0
  70. package/src/server/services/agentRuntime/AgentRuntimeService.test.ts +4 -1
  71. package/src/server/services/agentRuntime/AgentRuntimeService.ts +2 -1
  72. package/src/server/services/message/__tests__/index.test.ts +37 -0
  73. package/src/server/services/message/index.ts +6 -1
  74. package/src/services/aiAgent.ts +51 -0
  75. package/src/services/topic/index.ts +4 -0
  76. package/src/store/chat/agents/createAgentExecutors.ts +714 -12
  77. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +6 -1
  78. package/src/store/chat/slices/message/actions/query.ts +33 -1
  79. package/src/store/chat/slices/message/selectors/displayMessage.test.ts +10 -0
  80. package/src/store/chat/slices/message/selectors/displayMessage.ts +1 -0
  81. package/src/store/chat/slices/operation/types.ts +4 -0
  82. package/src/store/chat/slices/topic/action.test.ts +2 -1
  83. package/src/store/chat/slices/topic/action.ts +1 -1
@@ -407,18 +407,20 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
407
407
  * Execute a single async task
408
408
  *
409
409
  * This method triggers async task execution by returning a special state.
410
- * The AgentRuntime's executor will recognize this state and trigger the exec_task instruction.
410
+ * The AgentRuntime's executor will recognize this state and trigger the appropriate instruction.
411
411
  *
412
412
  * Flow:
413
- * 1. GTD tool returns stop: true with state.type = 'execTask'
414
- * 2. AgentRuntime executor recognizes the state and triggers exec_task instruction
415
- * 3. exec_task executor creates task message and polls for completion
413
+ * 1. GTD tool returns stop: true with state.type = 'execTask' or 'execClientTask'
414
+ * 2. AgentRuntime executor recognizes the state and triggers exec_task or exec_client_task instruction
415
+ * 3. The executor creates task message and handles execution
416
+ *
417
+ * @param params.runInClient - If true, returns 'execClientTask' state for client-side execution
416
418
  */
417
419
  execTask = async (
418
420
  params: ExecTaskParams,
419
421
  ctx: BuiltinToolContext,
420
422
  ): Promise<BuiltinToolResult> => {
421
- const { description, instruction, inheritMessages, timeout } = params;
423
+ const { description, instruction, inheritMessages, timeout, runInClient } = params;
422
424
 
423
425
  if (!description || !instruction) {
424
426
  return {
@@ -427,19 +429,25 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
427
429
  };
428
430
  }
429
431
 
432
+ const task = {
433
+ description,
434
+ inheritMessages,
435
+ instruction,
436
+ runInClient,
437
+ timeout,
438
+ };
439
+
440
+ // Determine state type based on runInClient
441
+ // If runInClient is true, return 'execClientTask' to trigger client-side executor
442
+ const stateType = runInClient ? 'execClientTask' : 'execTask';
443
+
430
444
  // Return stop: true with special state that AgentRuntime will recognize
431
- // The exec_task executor will be triggered by the runtime when it sees this state
432
445
  return {
433
- content: `🚀 Triggered async task for execution:\n- ${description}`,
446
+ content: `🚀 Triggered async task for ${runInClient ? 'client-side' : ''} execution:\n- ${description}`,
434
447
  state: {
435
448
  parentMessageId: ctx.messageId,
436
- task: {
437
- description,
438
- inheritMessages,
439
- instruction,
440
- timeout,
441
- },
442
- type: 'execTask',
449
+ task,
450
+ type: stateType,
443
451
  },
444
452
  stop: true,
445
453
  success: true,
@@ -450,12 +458,15 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
450
458
  * Execute one or more async tasks
451
459
  *
452
460
  * This method triggers async task execution by returning a special state.
453
- * The AgentRuntime's executor will recognize this state and trigger the exec_tasks instruction.
461
+ * The AgentRuntime's executor will recognize this state and trigger the appropriate instruction.
454
462
  *
455
463
  * Flow:
456
- * 1. GTD tool returns stop: true with state.type = 'execTasks'
457
- * 2. AgentRuntime executor recognizes the state and triggers exec_tasks instruction
458
- * 3. exec_tasks executor creates task messages and polls for completion
464
+ * 1. GTD tool returns stop: true with state.type = 'execTasks' or 'execClientTasks'
465
+ * 2. AgentRuntime executor recognizes the state and triggers exec_tasks or exec_client_tasks instruction
466
+ * 3. The executor creates task messages and handles execution
467
+ *
468
+ * Note: If any task has runInClient=true, all tasks will be routed to 'execClientTasks'.
469
+ * This is because client-side execution is the "special" case requiring local tool access.
459
470
  */
460
471
  execTasks = async (
461
472
  params: ExecTasksParams,
@@ -473,14 +484,20 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
473
484
  const taskCount = tasks.length;
474
485
  const taskList = tasks.map((t, i) => `${i + 1}. ${t.description}`).join('\n');
475
486
 
487
+ // Check if any task requires client-side execution
488
+ const hasClientTasks = tasks.some((t) => t.runInClient);
489
+
490
+ // Determine state type: if any task needs client-side, route all to client executor
491
+ const stateType = hasClientTasks ? 'execClientTasks' : 'execTasks';
492
+ const executionMode = hasClientTasks ? 'client-side' : '';
493
+
476
494
  // Return stop: true with special state that AgentRuntime will recognize
477
- // The exec_tasks executor will be triggered by the runtime when it sees this state
478
495
  return {
479
- content: `🚀 Triggered ${taskCount} async task${taskCount > 1 ? 's' : ''} for execution:\n${taskList}`,
496
+ content: `🚀 Triggered ${taskCount} async task${taskCount > 1 ? 's' : ''} for ${executionMode} execution:\n${taskList}`,
480
497
  state: {
481
498
  parentMessageId: ctx.messageId,
482
499
  tasks,
483
- type: 'execTasks',
500
+ type: stateType,
484
501
  },
485
502
  stop: true,
486
503
  success: true,
@@ -1,5 +1,6 @@
1
1
  import type { BuiltinToolManifest } from '@lobechat/types';
2
2
 
3
+ import { isDesktop } from './const';
3
4
  import { systemPrompt } from './systemRole';
4
5
  import { GTDApiName } from './types';
5
6
 
@@ -171,6 +172,13 @@ export const GTDManifest: BuiltinToolManifest = {
171
172
  'Whether to inherit context messages from the parent conversation. Default is false.',
172
173
  type: 'boolean',
173
174
  },
175
+ ...(isDesktop && {
176
+ runInClient: {
177
+ description:
178
+ 'Whether to run on the desktop client (for local file/shell access). MUST be true when task requires local-system tools. Default is false (server execution).',
179
+ type: 'boolean',
180
+ },
181
+ }),
174
182
  timeout: {
175
183
  description: 'Optional timeout in milliseconds. Default is 30 minutes.',
176
184
  type: 'number',
@@ -203,6 +211,13 @@ export const GTDManifest: BuiltinToolManifest = {
203
211
  'Whether to inherit context messages from the parent conversation. Default is false.',
204
212
  type: 'boolean',
205
213
  },
214
+ ...(isDesktop && {
215
+ runInClient: {
216
+ description:
217
+ 'Whether to run on the desktop client (for local file/shell access). MUST be true when task requires local-system tools. Default is false (server execution).',
218
+ type: 'boolean',
219
+ },
220
+ }),
206
221
  timeout: {
207
222
  description: 'Optional timeout in milliseconds. Default is 30 minutes.',
208
223
  type: 'number',
@@ -1,3 +1,35 @@
1
+ import { isDesktop } from './const';
2
+
3
+ const runInClientSection = `
4
+ <run_in_client>
5
+ **IMPORTANT: When to use \`runInClient: true\` for async tasks**
6
+
7
+ The \`runInClient\` parameter controls WHERE the async task executes:
8
+ - \`runInClient: false\` (default): Task runs on the **server** - suitable for web searches, API calls, general research
9
+ - \`runInClient: true\`: Task runs on the **desktop client** - required for local system access
10
+
11
+ **MUST set \`runInClient: true\` when the task involves:**
12
+ - Reading or writing local files (via \`local-system\` tool)
13
+ - Executing shell commands on the user's machine
14
+ - Accessing local directories or file system
15
+ - Any operation that requires desktop-only local tools
16
+
17
+ **Keep \`runInClient: false\` (or omit) when:**
18
+ - Task only needs web searches or API calls
19
+ - Task processes data that doesn't require local file access
20
+ - Task can be fully completed with server-side capabilities
21
+
22
+ **Note:** \`runInClient\` only has effect on the **desktop app**. On web platform, tasks always run on the server regardless of this setting.
23
+
24
+ **Examples:**
25
+ - "Research Python best practices" → \`runInClient: false\` (web search only)
26
+ - "Organize files in my Downloads folder" → \`runInClient: true\` (local file access required)
27
+ - "Read the project README and summarize it" → \`runInClient: true\` (local file read required)
28
+ - "Find trending tech news" → \`runInClient: false\` (web search only)
29
+ - "Create a new directory structure for my project" → \`runInClient: true\` (local shell/file required)
30
+ </run_in_client>
31
+ `;
32
+
1
33
  export const systemPrompt = `You have GTD (Getting Things Done) tools to help manage plans, todos and tasks effectively. These tools support three levels of task management:
2
34
 
3
35
  - **Plan**: A high-level strategic document describing goals, context, and overall direction. Plans do NOT contain actionable steps - they define the "what" and "why". **Plans should be stable once created** - they represent the overarching objective that rarely changes.
@@ -85,7 +117,7 @@ Use \`execTask\` for a single task, \`execTasks\` for multiple parallel tasks.
85
117
  - User asks a factual question you know → Just answer directly
86
118
  - User wants multiple independent analyses → execTasks (parallel execution)
87
119
  </when_to_use>
88
-
120
+ ${isDesktop ? runInClientSection : ''}
89
121
  <best_practices>
90
122
  - **Plan first, then todos**: Always start with a plan unless explicitly told otherwise
91
123
  - **Separate concerns**: Plans describe goals; Todos list actions
@@ -14,43 +14,25 @@ export const GTDApiName = {
14
14
  /** Clear completed or all todos */
15
15
  clearTodos: 'clearTodos',
16
16
 
17
-
18
17
  // ==================== Planning ====================
19
- /** Create a structured plan by breaking down a goal into actionable steps */
20
- createPlan: 'createPlan',
18
+ /** Create a structured plan by breaking down a goal into actionable steps */
19
+ createPlan: 'createPlan',
21
20
 
22
-
23
-
21
+ /** Create new todo items */
22
+ createTodos: 'createTodos',
24
23
 
25
- /** Create new todo items */
26
- createTodos: 'createTodos',
24
+ // ==================== Async Tasks ====================
25
+ /** Execute a single async task */
26
+ execTask: 'execTask',
27
27
 
28
-
29
-
30
-
31
- // ==================== Async Tasks ====================
32
- /** Execute a single async task */
33
- execTask: 'execTask',
28
+ /** Execute one or more async tasks */
29
+ execTasks: 'execTasks',
34
30
 
35
-
36
-
31
+ /** Update an existing plan */
32
+ updatePlan: 'updatePlan',
37
33
 
38
-
39
- /** Execute one or more async tasks */
40
- execTasks: 'execTasks',
41
-
42
-
43
-
44
-
45
-
46
-
47
- /** Update an existing plan */
48
- updatePlan: 'updatePlan',
49
-
50
-
51
-
52
- /** Update todo items with batch operations (add, update, remove, complete, processing) */
53
- updateTodos: 'updateTodos',
34
+ /** Update todo items with batch operations (add, update, remove, complete, processing) */
35
+ updateTodos: 'updateTodos',
54
36
  } as const;
55
37
 
56
38
  export type GTDApiNameType = (typeof GTDApiName)[keyof typeof GTDApiName];
@@ -258,6 +240,14 @@ export interface ExecTaskItem {
258
240
  inheritMessages?: boolean;
259
241
  /** Detailed instruction/prompt for the task execution */
260
242
  instruction: string;
243
+ /**
244
+ * Whether to execute the task on the client side (desktop only).
245
+ * When true and running on desktop, the task will be executed locally
246
+ * with access to local tools (file system, shell commands, etc.).
247
+ *
248
+ * MUST be true when task requires local-system tools.
249
+ */
250
+ runInClient?: boolean;
261
251
  /** Timeout in milliseconds (optional, default 30 minutes) */
262
252
  timeout?: number;
263
253
  }
@@ -273,6 +263,14 @@ export interface ExecTaskParams {
273
263
  inheritMessages?: boolean;
274
264
  /** Detailed instruction/prompt for the task execution */
275
265
  instruction: string;
266
+ /**
267
+ * Whether to execute the task on the client side (desktop only).
268
+ * When true and running on desktop, the task will be executed locally
269
+ * with access to local tools (file system, shell commands, etc.).
270
+ *
271
+ * MUST be true when task requires local-system tools.
272
+ */
273
+ runInClient?: boolean;
276
274
  /** Timeout in milliseconds (optional, default 30 minutes) */
277
275
  timeout?: number;
278
276
  }
@@ -287,7 +285,7 @@ export interface ExecTasksParams {
287
285
  }
288
286
 
289
287
  /**
290
- * State returned after triggering exec_task
288
+ * State returned after triggering exec_task (server-side)
291
289
  */
292
290
  export interface ExecTaskState {
293
291
  /** Parent message ID (tool message) */
@@ -299,7 +297,7 @@ export interface ExecTaskState {
299
297
  }
300
298
 
301
299
  /**
302
- * State returned after triggering exec_tasks
300
+ * State returned after triggering exec_tasks (server-side)
303
301
  */
304
302
  export interface ExecTasksState {
305
303
  /** Parent message ID (tool message) */
@@ -309,3 +307,27 @@ export interface ExecTasksState {
309
307
  /** Type identifier for render component */
310
308
  type: 'execTasks';
311
309
  }
310
+
311
+ /**
312
+ * State returned after triggering exec_client_task (client-side, desktop only)
313
+ */
314
+ export interface ExecClientTaskState {
315
+ /** Parent message ID (tool message) */
316
+ parentMessageId: string;
317
+ /** The task definition that was triggered */
318
+ task: ExecTaskItem;
319
+ /** Type identifier for render component */
320
+ type: 'execClientTask';
321
+ }
322
+
323
+ /**
324
+ * State returned after triggering exec_client_tasks (client-side, desktop only)
325
+ */
326
+ export interface ExecClientTasksState {
327
+ /** Parent message ID (tool message) */
328
+ parentMessageId: string;
329
+ /** Array of task definitions that were triggered */
330
+ tasks: ExecTaskItem[];
331
+ /** Type identifier for render component */
332
+ type: 'execClientTasks';
333
+ }
@@ -15,6 +15,7 @@ const styles = createStaticStyles(({ css }) => ({
15
15
  lineRange: css`
16
16
  flex-shrink: 0;
17
17
  margin-inline-start: 4px;
18
+ font-size: 12px;
18
19
  opacity: 0.7;
19
20
  `,
20
21
  }));
@@ -47,7 +47,7 @@ export const RunCommandInspector = memo<BuiltinInspectorProps<RunCommandParams,
47
47
 
48
48
  // Get execution result from pluginState
49
49
  const result = pluginState?.result;
50
- const isSuccess = result?.success && result?.exit_code === 0;
50
+ const isSuccess = result?.success || result?.exit_code === 0;
51
51
 
52
52
  return (
53
53
  <div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
@@ -32,7 +32,7 @@ const WriteFile = memo<BuiltinRenderProps<WriteLocalFileParams>>(({ args }) => {
32
32
 
33
33
  if (ext === 'md' || ext === 'mdx') {
34
34
  return (
35
- <Markdown style={{ maxHeight: 240, overflow: 'auto', padding: '0 8px' }}>
35
+ <Markdown style={{ maxHeight: 240, overflow: 'auto', padding: '0 8px' }} variant={'chat'}>
36
36
  {args.content}
37
37
  </Markdown>
38
38
  );
@@ -19,7 +19,11 @@ export const WriteFileStreaming = memo<BuiltinStreamingProps<WriteLocalFileParam
19
19
 
20
20
  // Use Markdown for .md files, Highlighter for others
21
21
  if (ext === 'md' || ext === 'mdx') {
22
- return <Markdown style={{ overflow: 'auto' }}>{content}</Markdown>;
22
+ return (
23
+ <Markdown animated style={{ overflow: 'auto' }} variant={'chat'}>
24
+ {content}
25
+ </Markdown>
26
+ );
23
27
  }
24
28
 
25
29
  return (
@@ -12,11 +12,17 @@ Note: The list of existing documents is automatically provided in the context, s
12
12
 
13
13
  <when_to_use>
14
14
  **Save to Notebook when**:
15
- - Creating reports, analyses, or summaries that should persist
16
15
  - User explicitly asks to "save", "write down", or "document" something
17
- - Generating structured content like articles, notes, or reports
18
- - Web browsing results worth keeping for later reference
19
- - Any content the user might want to review or edit later
16
+ - Creating substantial content meant to persist (reports, articles, analyses)
17
+ - Generating structured deliverables the user will likely reference later
18
+ - Web browsing results worth keeping for future reference
19
+
20
+ **Do NOT save to Notebook when**:
21
+ - User asks a simple question (just answer directly)
22
+ - Providing explanations, tutorials, or how-to responses
23
+ - Having casual conversations or discussions
24
+ - Content is short, temporary, or doesn't need persistence
25
+ - User didn't request saving and the content isn't a clear deliverable
20
26
 
21
27
  **Document Types**:
22
28
  - markdown: General formatted text (default)
@@ -44,8 +50,22 @@ Note: The list of existing documents is automatically provided in the context, s
44
50
 
45
51
  <response_format>
46
52
  After creating/updating documents:
47
- - Briefly confirm the action: "Saved to Notebook: [title]"
48
- - Don't repeat the full content in your response
49
- - Mention that user can view/edit in the Portal sidebar
53
+ - Confirm the action briefly: "Saved to Notebook: [title]"
54
+ - Provide a short summary (2-4 bullet points max) highlighting only the key takeaways
55
+ - NEVER repeat or rephrase the full document content - the user just saw it being created
56
+ - Optionally mention they can view/edit in the sidebar
57
+
58
+ ❌ Bad (repeating content):
59
+ "I've saved 'Project Plan' which covers the three-phase implementation approach. Phase 1 focuses on user research and requirements gathering from March to April. Phase 2 involves design and prototyping from May to June. Phase 3 covers development and testing from July to September..."
60
+
61
+ ✅ Good (brief summary):
62
+ "Saved to Notebook: Project Plan
63
+
64
+ Key points:
65
+ - 3-phase approach: Research → Design → Development
66
+ - Timeline: March - September
67
+ - 5 team members involved
68
+
69
+ You can edit it in the sidebar."
50
70
  </response_format>
51
71
  `;
@@ -31,8 +31,20 @@ export class FlatListBuilder {
31
31
  const flatList: Message[] = [];
32
32
  const processedIds = new Set<string>();
33
33
 
34
+ // Determine the root parentId
35
+ // Normal case: start from null (messages with no parentId)
36
+ // Orphan case: if all messages have parentId (thread mode), use first message as root
37
+ let rootParentId: string | null = null;
38
+
39
+ const hasRootMessages = this.childrenMap.has(null) && this.childrenMap.get(null)!.length > 0;
40
+ if (!hasRootMessages && messages.length > 0) {
41
+ // All messages have parentId - this is orphan/thread mode
42
+ // Use the first message's parentId as the virtual root
43
+ rootParentId = messages[0].parentId ?? null;
44
+ }
45
+
34
46
  // Build the active path by traversing from root
35
- this.buildFlatListRecursive(null, flatList, processedIds, messages);
47
+ this.buildFlatListRecursive(rootParentId, flatList, processedIds, messages);
36
48
 
37
49
  return flatList;
38
50
  }
@@ -517,5 +517,45 @@ describe('FlatListBuilder', () => {
517
517
  // User message should not have branch info since we're in optimistic update mode
518
518
  expect((result[0] as any).siblingCount).toBeUndefined();
519
519
  });
520
+
521
+ it('should handle orphan messages where all have parentId (thread mode)', () => {
522
+ // Scenario: Thread messages where the parent (source message) is not in the query result
523
+ // All messages have parentId pointing to a message not in the array
524
+ const messages: Message[] = [
525
+ {
526
+ content: 'Thread user message',
527
+ createdAt: 0,
528
+ id: 'msg-1',
529
+ parentId: 'source-msg-not-in-array',
530
+ role: 'user',
531
+ updatedAt: 0,
532
+ },
533
+ {
534
+ content: 'Thread assistant reply',
535
+ createdAt: 0,
536
+ id: 'msg-2',
537
+ parentId: 'msg-1',
538
+ role: 'assistant',
539
+ updatedAt: 0,
540
+ },
541
+ {
542
+ content: 'Thread user follow-up',
543
+ createdAt: 0,
544
+ id: 'msg-3',
545
+ parentId: 'msg-2',
546
+ role: 'user',
547
+ updatedAt: 0,
548
+ },
549
+ ];
550
+
551
+ const builder = createBuilder(messages);
552
+ const result = builder.flatten(messages);
553
+
554
+ // Should flatten all messages correctly using first message's parentId as virtual root
555
+ expect(result).toHaveLength(3);
556
+ expect(result[0].id).toBe('msg-1');
557
+ expect(result[1].id).toBe('msg-2');
558
+ expect(result[2].id).toBe('msg-3');
559
+ });
520
560
  });
521
561
  });
@@ -1,10 +1,10 @@
1
1
  import { eq, sql } from 'drizzle-orm';
2
2
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
3
 
4
+ import { getTestDB } from '../../../core/getTestDB';
4
5
  import { agents, messages, sessions, threads, topics, users } from '../../../schemas';
5
6
  import { LobeChatDatabase } from '../../../type';
6
7
  import { MessageModel } from '../../message';
7
- import { getTestDB } from '../../../core/getTestDB';
8
8
 
9
9
  const serverDB: LobeChatDatabase = await getTestDB();
10
10
 
@@ -337,6 +337,90 @@ describe('MessageModel thread query', () => {
337
337
  expect(result.map((m) => m.id)).toEqual(['msg2', 'thread-msg1', 'thread-msg2']);
338
338
  });
339
339
 
340
+ it('should return only thread messages for Isolation type (no parent messages)', async () => {
341
+ await serverDB.transaction(async (trx) => {
342
+ await trx.insert(agents).values([{ id: 'agent1', userId }]);
343
+ await trx.insert(topics).values([{ id: 'topic1', userId }]);
344
+
345
+ // Create thread with Isolation type
346
+ await trx.insert(threads).values([
347
+ {
348
+ id: 'thread1',
349
+ userId,
350
+ topicId: 'topic1',
351
+ sourceMessageId: 'msg2',
352
+ type: 'isolation',
353
+ },
354
+ ]);
355
+
356
+ // Create main conversation messages
357
+ await trx.insert(messages).values([
358
+ {
359
+ id: 'msg1',
360
+ userId,
361
+ agentId: 'agent1',
362
+ topicId: 'topic1',
363
+ threadId: null,
364
+ role: 'user',
365
+ content: 'first message',
366
+ createdAt: new Date('2023-01-01'),
367
+ },
368
+ {
369
+ id: 'msg2',
370
+ userId,
371
+ agentId: 'agent1',
372
+ topicId: 'topic1',
373
+ threadId: null,
374
+ role: 'assistant',
375
+ content: 'second message - source',
376
+ createdAt: new Date('2023-01-02'),
377
+ },
378
+ {
379
+ id: 'msg3',
380
+ userId,
381
+ agentId: 'agent1',
382
+ topicId: 'topic1',
383
+ threadId: null,
384
+ role: 'user',
385
+ content: 'third message',
386
+ createdAt: new Date('2023-01-03'),
387
+ },
388
+ // Thread messages
389
+ {
390
+ id: 'thread-msg1',
391
+ userId,
392
+ agentId: 'agent1',
393
+ topicId: 'topic1',
394
+ threadId: 'thread1',
395
+ role: 'user',
396
+ content: 'thread message 1',
397
+ createdAt: new Date('2023-01-02T10:00:00'),
398
+ },
399
+ {
400
+ id: 'thread-msg2',
401
+ userId,
402
+ agentId: 'agent1',
403
+ topicId: 'topic1',
404
+ threadId: 'thread1',
405
+ role: 'assistant',
406
+ content: 'thread message 2',
407
+ createdAt: new Date('2023-01-02T11:00:00'),
408
+ },
409
+ ]);
410
+ });
411
+
412
+ const result = await messageModel.query({
413
+ agentId: 'agent1',
414
+ topicId: 'topic1',
415
+ threadId: 'thread1',
416
+ });
417
+
418
+ // For Isolation: should include ONLY thread messages, NO parent messages at all
419
+ // Should NOT include msg1, msg2 (source), or msg3
420
+ expect(result).toHaveLength(2);
421
+ expect(result.map((m) => m.id)).toEqual(['thread-msg1', 'thread-msg2']);
422
+ });
423
+
340
424
  it('should return only thread messages when thread has no sourceMessageId', async () => {
341
425
  await serverDB.transaction(async (trx) => {
342
426
  await trx.insert(sessions).values([{ id: 'session1', userId }]);
@@ -572,6 +656,55 @@ describe('MessageModel thread query', () => {
572
656
  expect(result[0].id).toBe('msg2');
573
657
  });
574
658
 
659
+ it('should return empty array for Isolation thread type', async () => {
660
+ await serverDB.transaction(async (trx) => {
661
+ await trx.insert(agents).values([{ id: 'agent1', userId }]);
662
+ await trx.insert(topics).values([{ id: 'topic1', userId }]);
663
+
664
+ await trx.insert(messages).values([
665
+ {
666
+ id: 'msg1',
667
+ userId,
668
+ agentId: 'agent1',
669
+ topicId: 'topic1',
670
+ threadId: null,
671
+ role: 'user',
672
+ content: 'first',
673
+ createdAt: new Date('2023-01-01'),
674
+ },
675
+ {
676
+ id: 'msg2',
677
+ userId,
678
+ agentId: 'agent1',
679
+ topicId: 'topic1',
680
+ threadId: null,
681
+ role: 'assistant',
682
+ content: 'second - source',
683
+ createdAt: new Date('2023-01-02'),
684
+ },
685
+ {
686
+ id: 'msg3',
687
+ userId,
688
+ agentId: 'agent1',
689
+ topicId: 'topic1',
690
+ threadId: null,
691
+ role: 'user',
692
+ content: 'third',
693
+ createdAt: new Date('2023-01-03'),
694
+ },
695
+ ]);
696
+ });
697
+
698
+ const result = await messageModel.getThreadParentMessages({
699
+ sourceMessageId: 'msg2',
700
+ topicId: 'topic1',
701
+ threadType: 'isolation' as any,
702
+ });
703
+
704
+ // Isolation should return empty array (no parent messages)
705
+ expect(result).toHaveLength(0);
706
+ });
707
+
575
708
  it('should return all messages up to source message for Continuation thread type', async () => {
576
709
  await serverDB.transaction(async (trx) => {
577
710
  await trx.insert(sessions).values([{ id: 'session1', userId }]);
@@ -431,6 +431,7 @@ export class MessageModel {
431
431
  return [
432
432
  t.sourceMessageId!,
433
433
  {
434
+ clientMode: metadata?.clientMode as boolean | undefined,
434
435
  duration: metadata?.duration as number | undefined,
435
436
  status: t.status as ThreadStatus,
436
437
  threadId: t.threadId,
@@ -678,10 +679,11 @@ export class MessageModel {
678
679
  * @param params - Parameters for getting parent messages
679
680
  * @param params.sourceMessageId - The ID of the source message that started the thread
680
681
  * @param params.topicId - The topic ID the thread belongs to
681
- * @param params.threadType - The type of thread (Continuation or Standalone)
682
+ * @param params.threadType - The type of thread (Continuation, Standalone, or Isolation)
682
683
  * @returns Parent messages based on thread type:
683
684
  * - Continuation: All messages from the topic up to and including the source message
684
685
  * - Standalone: Only the source message itself
686
+ * - Isolation: No parent messages (completely isolated thread)
685
687
  */
686
688
  getThreadParentMessages = async (params: {
687
689
  sourceMessageId: string;
@@ -690,6 +692,11 @@ export class MessageModel {
690
692
  }): Promise<DBMessageItem[]> => {
691
693
  const { sourceMessageId, topicId, threadType } = params;
692
694
 
695
+ // For Isolation type, return empty array (no parent messages)
696
+ if (threadType === ThreadType.Isolation) {
697
+ return [];
698
+ }
699
+
693
700
  // For Standalone type, only return the source message
694
701
  if (threadType === ThreadType.Standalone) {
695
702
  const sourceMessage = await this.db.query.messages.findFirst({