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

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 (55) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/changelog/v1.json +9 -0
  3. package/locales/en-US/chat.json +6 -1
  4. package/locales/zh-CN/chat.json +5 -0
  5. package/package.json +1 -1
  6. package/packages/agent-runtime/src/agents/GeneralChatAgent.ts +24 -0
  7. package/packages/agent-runtime/src/agents/__tests__/GeneralChatAgent.test.ts +210 -0
  8. package/packages/agent-runtime/src/types/instruction.ts +46 -2
  9. package/packages/builtin-tool-gtd/src/const.ts +1 -0
  10. package/packages/builtin-tool-gtd/src/executor/index.ts +38 -21
  11. package/packages/builtin-tool-gtd/src/manifest.ts +15 -0
  12. package/packages/builtin-tool-gtd/src/systemRole.ts +33 -1
  13. package/packages/builtin-tool-gtd/src/types.ts +55 -33
  14. package/packages/builtin-tool-local-system/src/client/Inspector/ReadLocalFile/index.tsx +1 -0
  15. package/packages/builtin-tool-local-system/src/client/Inspector/RunCommand/index.tsx +1 -1
  16. package/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx +1 -1
  17. package/packages/builtin-tool-local-system/src/client/Streaming/WriteFile/index.tsx +5 -1
  18. package/packages/builtin-tool-notebook/src/systemRole.ts +27 -7
  19. package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +13 -1
  20. package/packages/conversation-flow/src/transformation/__tests__/FlatListBuilder.test.ts +40 -0
  21. package/packages/database/src/models/__tests__/messages/message.thread-query.test.ts +134 -1
  22. package/packages/database/src/models/message.ts +8 -1
  23. package/packages/database/src/models/thread.ts +1 -1
  24. package/packages/types/src/message/ui/chat.ts +2 -0
  25. package/packages/types/src/topic/thread.ts +20 -0
  26. package/src/components/StreamingMarkdown/index.tsx +10 -43
  27. package/src/features/Conversation/Messages/AssistantGroup/components/MessageContent.tsx +0 -2
  28. package/src/features/Conversation/Messages/Task/ClientTaskDetail/CompletedState.tsx +108 -0
  29. package/src/features/Conversation/Messages/Task/ClientTaskDetail/InitializingState.tsx +66 -0
  30. package/src/features/Conversation/Messages/Task/ClientTaskDetail/InstructionAccordion.tsx +63 -0
  31. package/src/features/Conversation/Messages/Task/ClientTaskDetail/ProcessingState.tsx +123 -0
  32. package/src/features/Conversation/Messages/Task/ClientTaskDetail/index.tsx +106 -0
  33. package/src/features/Conversation/Messages/Task/TaskDetailPanel/index.tsx +1 -0
  34. package/src/features/Conversation/Messages/Task/index.tsx +11 -6
  35. package/src/features/Conversation/Messages/Tasks/TaskItem/TaskTitle.tsx +3 -2
  36. package/src/features/Conversation/Messages/Tasks/shared/InitializingState.tsx +0 -4
  37. package/src/features/Conversation/Messages/Tasks/shared/utils.ts +22 -1
  38. package/src/features/Conversation/Messages/components/ContentLoading.tsx +1 -1
  39. package/src/features/Conversation/components/Thinking/index.tsx +9 -30
  40. package/src/features/Conversation/store/slices/data/action.ts +2 -3
  41. package/src/features/NavPanel/components/BackButton.tsx +10 -13
  42. package/src/features/NavPanel/components/NavPanelDraggable.tsx +4 -0
  43. package/src/hooks/useAutoScroll.ts +117 -0
  44. package/src/locales/default/chat.ts +6 -1
  45. package/src/server/routers/lambda/aiAgent.ts +239 -1
  46. package/src/server/routers/lambda/thread.ts +2 -0
  47. package/src/server/services/message/__tests__/index.test.ts +37 -0
  48. package/src/server/services/message/index.ts +6 -1
  49. package/src/services/aiAgent.ts +51 -0
  50. package/src/store/chat/agents/createAgentExecutors.ts +714 -12
  51. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +6 -1
  52. package/src/store/chat/slices/message/actions/query.ts +33 -1
  53. package/src/store/chat/slices/message/selectors/displayMessage.test.ts +10 -0
  54. package/src/store/chat/slices/message/selectors/displayMessage.ts +1 -0
  55. package/src/store/chat/slices/operation/types.ts +4 -0
@@ -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({
@@ -32,7 +32,7 @@ export class ThreadModel {
32
32
  // @ts-ignore
33
33
  const [result] = await this.db
34
34
  .insert(threads)
35
- .values({ ...params, status: ThreadStatus.Active, userId: this.userId })
35
+ .values({ status: ThreadStatus.Active, ...params, userId: this.userId })
36
36
  .onConflictDoNothing()
37
37
  .returning();
38
38
 
@@ -64,6 +64,8 @@ interface UIMessageBranch {
64
64
  * Retrieved from the associated Thread via sourceMessageId
65
65
  */
66
66
  export interface TaskDetail {
67
+ /** Whether this task runs in client mode (local execution) */
68
+ clientMode?: boolean;
67
69
  /** Task completion time (ISO string) */
68
70
  completedAt?: string;
69
71
  /** Execution duration in milliseconds */
@@ -23,6 +23,8 @@ export enum ThreadStatus {
23
23
  * Metadata for Thread, used for agent task execution
24
24
  */
25
25
  export interface ThreadMetadata {
26
+ /** Whether this thread runs in client mode (local execution) */
27
+ clientMode?: boolean;
26
28
  /** Task completion time */
27
29
  completedAt?: string;
28
30
  /** Execution duration in milliseconds */
@@ -68,16 +70,34 @@ export interface CreateThreadParams {
68
70
  agentId?: string;
69
71
  /** Group ID for group chat context */
70
72
  groupId?: string;
73
+ /** Initial metadata for the thread */
74
+ metadata?: ThreadMetadata;
71
75
  parentThreadId?: string;
72
76
  sourceMessageId?: string;
77
+ /** Initial status (defaults to Active) */
78
+ status?: ThreadStatus;
73
79
  title?: string;
74
80
  topicId: string;
75
81
  type: IThreadType;
76
82
  }
77
83
 
84
+ export const threadMetadataSchema = z.object({
85
+ clientMode: z.boolean().optional(),
86
+ completedAt: z.string().optional(),
87
+ duration: z.number().optional(),
88
+ error: z.any().optional(),
89
+ operationId: z.string().optional(),
90
+ startedAt: z.string().optional(),
91
+ totalCost: z.number().optional(),
92
+ totalMessages: z.number().optional(),
93
+ totalTokens: z.number().optional(),
94
+ totalToolCalls: z.number().optional(),
95
+ });
96
+
78
97
  export const createThreadSchema = z.object({
79
98
  agentId: z.string().optional(),
80
99
  groupId: z.string().optional(),
100
+ metadata: threadMetadataSchema.optional(),
81
101
  parentThreadId: z.string().optional(),
82
102
  sourceMessageId: z.string().optional(),
83
103
  title: z.string().optional(),
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { Markdown, ScrollShadow } from '@lobehub/ui';
4
4
  import { createStaticStyles } from 'antd-style';
5
- import { memo, useCallback, useEffect, useRef, useState } from 'react';
5
+ import { type RefObject, memo, useEffect } from 'react';
6
+
7
+ import { useAutoScroll } from '@/hooks/useAutoScroll';
6
8
 
7
9
  const styles = createStaticStyles(({ css }) => ({
8
10
  container: css`
@@ -19,51 +21,16 @@ interface StreamingMarkdownProps {
19
21
  }
20
22
 
21
23
  const StreamingMarkdown = memo<StreamingMarkdownProps>(({ children, maxHeight = 400 }) => {
22
- const containerRef = useRef<HTMLDivElement>(null);
23
- const [userHasScrolled, setUserHasScrolled] = useState(false);
24
- const isAutoScrollingRef = useRef(false);
25
-
26
- // Handle user scroll detection
27
- const handleScroll = useCallback(() => {
28
- // Ignore scroll events triggered by auto-scroll
29
- if (isAutoScrollingRef.current) return;
30
-
31
- const container = containerRef.current;
32
- if (!container) return;
33
-
34
- // Check if user scrolled away from bottom
35
- const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
36
- const isAtBottom = distanceToBottom < 20;
37
-
38
- // If user scrolled up, stop auto-scrolling
39
- if (!isAtBottom) {
40
- setUserHasScrolled(true);
41
- }
42
- }, []);
43
-
44
- // Auto scroll to bottom when content changes (unless user has scrolled)
45
- useEffect(() => {
46
- if (userHasScrolled) return;
47
-
48
- const container = containerRef.current;
49
- if (!container) return;
50
-
51
- isAutoScrollingRef.current = true;
52
- requestAnimationFrame(() => {
53
- container.scrollTop = container.scrollHeight;
54
- // Reset the flag after scroll completes
55
- requestAnimationFrame(() => {
56
- isAutoScrollingRef.current = false;
57
- });
58
- });
59
- }, [children, userHasScrolled]);
24
+ const { ref, handleScroll, resetScrollLock } = useAutoScroll<HTMLDivElement>({
25
+ deps: [children],
26
+ });
60
27
 
61
- // Reset userHasScrolled when content is cleared (new stream starts)
28
+ // Reset scroll lock when content is cleared (new stream starts)
62
29
  useEffect(() => {
63
30
  if (!children) {
64
- setUserHasScrolled(false);
31
+ resetScrollLock();
65
32
  }
66
- }, [children]);
33
+ }, [children, resetScrollLock]);
67
34
 
68
35
  if (!children) return null;
69
36
 
@@ -72,7 +39,7 @@ const StreamingMarkdown = memo<StreamingMarkdownProps>(({ children, maxHeight =
72
39
  className={styles.container}
73
40
  offset={12}
74
41
  onScroll={handleScroll}
75
- ref={containerRef}
42
+ ref={ref as RefObject<HTMLDivElement>}
76
43
  size={12}
77
44
  style={{ maxHeight }}
78
45
  >
@@ -28,8 +28,6 @@ const MessageContent = memo<ContentBlockProps>(({ content, hasTools, id }) => {
28
28
  if (!content && !hasTools) return <ContentLoading id={id} />;
29
29
 
30
30
  if (content === LOADING_FLAT) {
31
- if (hasTools) return null;
32
-
33
31
  return <ContentLoading id={id} />;
34
32
  }
35
33