@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
@@ -1,6 +1,7 @@
1
1
  import type { GenericOAuthConfig } from 'better-auth/plugins';
2
2
  import type { SocialProviders } from 'better-auth/social-providers';
3
3
 
4
+ import { appEnv } from '@/envs/app';
4
5
  import { authEnv } from '@/envs/auth';
5
6
  import { BUILTIN_BETTER_AUTH_PROVIDERS } from '@/libs/better-auth/constants';
6
7
  import { parseSSOProviders } from '@/libs/better-auth/utils/server';
@@ -106,7 +107,7 @@ export const initBetterAuthSSOProviders = () => {
106
107
  if (config) {
107
108
  // the generic oidc callback url is /api/auth/oauth2/callback/{providerId}
108
109
  // different from builtin providers' /api/auth/callback/{providerId}
109
- config.redirectURI = `${authEnv.NEXT_PUBLIC_AUTH_URL || ''}/api/auth/callback/${definition.id}`;
110
+ config.redirectURI = `${appEnv.APP_URL}/api/auth/callback/${definition.id}`;
110
111
  genericOAuthProviders.push(config);
111
112
  }
112
113
  }
@@ -1,3 +1,4 @@
1
+ import { appEnv } from '@/envs/app';
1
2
  import { authEnv } from '@/envs/auth';
2
3
  import { getRedisConfig } from '@/envs/redis';
3
4
  import { initializeRedis, isRedisEnabled } from '@/libs/redis';
@@ -48,8 +49,7 @@ export const getTrustedOrigins = (enabledSSOProviders: string[]) => {
48
49
  }
49
50
 
50
51
  const defaults = [
51
- authEnv.NEXT_PUBLIC_AUTH_URL,
52
- normalizeOrigin(process.env.APP_URL),
52
+ normalizeOrigin(appEnv.APP_URL),
53
53
  normalizeOrigin(process.env.VERCEL_BRANCH_URL),
54
54
  normalizeOrigin(process.env.VERCEL_URL),
55
55
  MOBILE_APP_SCHEME,
@@ -237,9 +237,8 @@ export function defineConfig() {
237
237
  // ref: https://authjs.dev/getting-started/session-management/protecting
238
238
  if (isProtected) {
239
239
  logNextAuth('Request a protected route, redirecting to sign-in page');
240
- const authUrl = authEnv.NEXT_PUBLIC_AUTH_URL;
241
- const callbackUrl = `${authUrl}${req.nextUrl.pathname}${req.nextUrl.search}`;
242
- const nextLoginUrl = new URL('/next-auth/signin', authUrl);
240
+ const callbackUrl = `${appEnv.APP_URL}${req.nextUrl.pathname}${req.nextUrl.search}`;
241
+ const nextLoginUrl = new URL('/next-auth/signin', appEnv.APP_URL);
243
242
  nextLoginUrl.searchParams.set('callbackUrl', callbackUrl);
244
243
  const hl = req.nextUrl.searchParams.get('hl');
245
244
  if (hl) {
@@ -325,9 +324,8 @@ export function defineConfig() {
325
324
  // If request a protected route, redirect to sign-in page
326
325
  if (isProtected) {
327
326
  logBetterAuth('Request a protected route, redirecting to sign-in page');
328
- const authUrl = authEnv.NEXT_PUBLIC_AUTH_URL;
329
- const callbackUrl = `${authUrl}${req.nextUrl.pathname}${req.nextUrl.search}`;
330
- const signInUrl = new URL('/signin', authUrl);
327
+ const callbackUrl = `${appEnv.APP_URL}${req.nextUrl.pathname}${req.nextUrl.search}`;
328
+ const signInUrl = new URL('/signin', appEnv.APP_URL);
331
329
  signInUrl.searchParams.set('callbackUrl', callbackUrl);
332
330
  const hl = req.nextUrl.searchParams.get('hl');
333
331
  if (hl) {
@@ -230,6 +230,7 @@ export default {
230
230
  'noSelectedAgents': 'No members selected yet',
231
231
  'openInNewWindow': 'Open in New Window',
232
232
  'operation.execAgentRuntime': 'Preparing response',
233
+ 'operation.execClientTask': 'Executing task',
233
234
  'operation.sendMessage': 'Sending message',
234
235
  'owner': 'Group owner',
235
236
  'pageCopilot.title': 'Page Agent',
@@ -354,13 +355,17 @@ export default {
354
355
  'tab.profile': 'Agent Profile',
355
356
  'tab.search': 'Search',
356
357
  'task.activity.calling': 'Calling Skill...',
358
+ 'task.activity.clientExecuting': 'Executing locally...',
357
359
  'task.activity.generating': 'Generating response...',
358
360
  'task.activity.gotResult': 'Tool result received',
359
361
  'task.activity.toolCalling': 'Calling {{toolName}}...',
360
362
  'task.activity.toolResult': '{{toolName}} result received',
361
363
  'task.batchTasks': '{{count}} Batch Subtasks',
364
+ 'task.instruction': 'Task Instruction',
365
+ 'task.intermediateSteps': '{{count}} intermediate steps',
366
+ 'task.metrics.duration': '(took {{duration}})',
362
367
  'task.metrics.stepsShort': 'steps',
363
- 'task.metrics.toolCallsShort': 'tool uses',
368
+ 'task.metrics.toolCallsShort': 'skill uses',
364
369
  'task.status.cancelled': 'Task Cancelled',
365
370
  'task.status.failed': 'Task Failed',
366
371
  'task.status.initializing': 'Initializing task...',
@@ -170,6 +170,8 @@ export default {
170
170
 
171
171
  'fork.viewAllForks': 'View all forks',
172
172
 
173
+ 'groupAgents.tag': 'Group',
174
+
173
175
  'home.communityAgents': 'Community Agents',
174
176
 
175
177
  'home.featuredAssistants': 'Featured Agents',
@@ -285,6 +285,80 @@ describe('Topic Router Integration Tests', () => {
285
285
  });
286
286
  });
287
287
 
288
+ describe('batchDeleteByAgentId', () => {
289
+ it('should batch delete topics by agentId (new data)', async () => {
290
+ const caller = topicRouter.createCaller(createTestContext(userId));
291
+
292
+ // Create topics with agentId directly (new data structure)
293
+ const topicId1 = await caller.createTopic({
294
+ title: 'Agent Topic 1',
295
+ agentId: testAgentId,
296
+ });
297
+ const topicId2 = await caller.createTopic({
298
+ title: 'Agent Topic 2',
299
+ agentId: testAgentId,
300
+ });
301
+
302
+ // Batch delete by agentId
303
+ await caller.batchDeleteByAgentId({
304
+ agentId: testAgentId,
305
+ });
306
+
307
+ const remainingTopics = await serverDB.select().from(topics).where(eq(topics.userId, userId));
308
+
309
+ expect(remainingTopics).toHaveLength(0);
310
+ });
311
+
312
+ it('should batch delete topics by agentId (legacy sessionId data)', async () => {
313
+ const caller = topicRouter.createCaller(createTestContext(userId));
314
+
315
+ // Create topics with sessionId (legacy data structure)
316
+ await caller.createTopic({
317
+ title: 'Legacy Topic 1',
318
+ sessionId: testSessionId,
319
+ });
320
+ await caller.createTopic({
321
+ title: 'Legacy Topic 2',
322
+ sessionId: testSessionId,
323
+ });
324
+
325
+ // Batch delete by agentId should also delete legacy topics via sessionId mapping
326
+ await caller.batchDeleteByAgentId({
327
+ agentId: testAgentId,
328
+ });
329
+
330
+ const remainingTopics = await serverDB
331
+ .select()
332
+ .from(topics)
333
+ .where(eq(topics.sessionId, testSessionId));
334
+
335
+ expect(remainingTopics).toHaveLength(0);
336
+ });
337
+
338
+ it('should batch delete topics by agentId (mixed data)', async () => {
339
+ const caller = topicRouter.createCaller(createTestContext(userId));
340
+
341
+ // Create both new (agentId) and legacy (sessionId) topics
342
+ await caller.createTopic({
343
+ title: 'New Agent Topic',
344
+ agentId: testAgentId,
345
+ });
346
+ await caller.createTopic({
347
+ title: 'Legacy Session Topic',
348
+ sessionId: testSessionId,
349
+ });
350
+
351
+ // Batch delete by agentId should delete both
352
+ await caller.batchDeleteByAgentId({
353
+ agentId: testAgentId,
354
+ });
355
+
356
+ const remainingTopics = await serverDB.select().from(topics).where(eq(topics.userId, userId));
357
+
358
+ expect(remainingTopics).toHaveLength(0);
359
+ });
360
+ });
361
+
288
362
  describe('searchTopics', () => {
289
363
  it('should search topics using agentId', async () => {
290
364
  const caller = topicRouter.createCaller(createTestContext(userId));
@@ -1,5 +1,10 @@
1
1
  import { type AgentRuntimeContext } from '@lobechat/agent-runtime';
2
- import { type TaskCurrentActivity, type TaskStatusResult, ThreadStatus } from '@lobechat/types';
2
+ import {
3
+ type TaskCurrentActivity,
4
+ type TaskStatusResult,
5
+ ThreadStatus,
6
+ ThreadType,
7
+ } from '@lobechat/types';
3
8
  import { TRPCError } from '@trpc/server';
4
9
  import debug from 'debug';
5
10
  import pMap from 'p-map';
@@ -154,6 +159,49 @@ const ExecSubAgentTaskSchema = z.object({
154
159
  topicId: z.string(),
155
160
  });
156
161
 
162
+ /**
163
+ * Schema for createClientTaskThread - create Thread for client-side task execution
164
+ * This is used when runInClient=true on desktop client
165
+ */
166
+ const CreateClientTaskThreadSchema = z.object({
167
+ /** The Agent ID to execute the task */
168
+ agentId: z.string(),
169
+ /** The Group ID (optional, only for Group mode) */
170
+ groupId: z.string().optional(),
171
+ /** Initial user message content (task instruction) */
172
+ instruction: z.string(),
173
+ /** The parent message ID (task message) */
174
+ parentMessageId: z.string(),
175
+ /** Task title (shown in UI, used as thread title) */
176
+ title: z.string().optional(),
177
+ /** The Topic ID */
178
+ topicId: z.string(),
179
+ });
180
+
181
+ /**
182
+ * Schema for updateClientTaskThreadStatus - update Thread status after client-side execution
183
+ */
184
+ const UpdateClientTaskThreadStatusSchema = z.object({
185
+ /** Completion reason */
186
+ completionReason: z.enum(['done', 'error', 'interrupted']),
187
+ /** Error message if failed */
188
+ error: z.string().optional(),
189
+ /** Thread metadata to update */
190
+ metadata: z
191
+ .object({
192
+ totalCost: z.number().optional(),
193
+ totalMessages: z.number().optional(),
194
+ totalSteps: z.number().optional(),
195
+ totalTokens: z.number().optional(),
196
+ totalToolCalls: z.number().optional(),
197
+ })
198
+ .optional(),
199
+ /** Result content (last assistant message) */
200
+ resultContent: z.string().optional(),
201
+ /** The Thread ID */
202
+ threadId: z.string(),
203
+ });
204
+
157
205
  /**
158
206
  * Schema for interruptTask - interrupt a running task
159
207
  */
@@ -184,6 +232,100 @@ const aiAgentProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
184
232
  });
185
233
 
186
234
  export const aiAgentRouter = router({
235
+ /**
236
+ * Create Thread for client-side task execution
237
+ *
238
+ * This endpoint is called by desktop client when runInClient=true.
239
+ * It creates the Thread but does NOT execute the task - execution happens on client side.
240
+ */
241
+ createClientTaskThread: aiAgentProcedure
242
+ .input(CreateClientTaskThreadSchema)
243
+ .mutation(async ({ input, ctx }) => {
244
+ const { agentId, groupId, instruction, parentMessageId, title, topicId } = input;
245
+
246
+ log('createClientTaskThread: agentId=%s, groupId=%s', agentId, groupId);
247
+
248
+ try {
249
+ // 1. Create Thread for isolated task execution
250
+ const startedAt = new Date().toISOString();
251
+ const thread = await ctx.threadModel.create({
252
+ agentId,
253
+ groupId,
254
+ metadata: { clientMode: true, startedAt },
255
+ sourceMessageId: parentMessageId,
256
+ status: ThreadStatus.Processing,
257
+ title,
258
+ topicId,
259
+ type: ThreadType.Isolation,
260
+ });
261
+
262
+ if (!thread) {
263
+ throw new TRPCError({
264
+ code: 'INTERNAL_SERVER_ERROR',
265
+ message: 'Failed to create thread for task execution',
266
+ });
267
+ }
268
+
269
+ log('createClientTaskThread: created thread %s', thread.id);
270
+
271
+ // 2. Create initial user message (persisted to database)
272
+ const userMessage = await ctx.messageModel.create({
273
+ agentId,
274
+ content: instruction,
275
+ parentId: parentMessageId,
276
+ role: 'user',
277
+ threadId: thread.id,
278
+ topicId,
279
+ });
280
+
281
+ log('createClientTaskThread: created user message %s', userMessage.id);
282
+
283
+ // 3. Query thread messages and main chat messages in parallel
284
+ const [threadMessages, messages] = await Promise.all([
285
+ // Thread messages (messages within this thread)
286
+ ctx.messageModel.query({
287
+ agentId,
288
+ threadId: thread.id,
289
+ topicId,
290
+ }),
291
+ // Main chat messages (messages without threadId, includes updated taskDetail)
292
+ ctx.messageModel.query({
293
+ agentId,
294
+ topicId,
295
+ // No threadId - matchThread will filter for threadId IS NULL (main chat)
296
+ }),
297
+ ]);
298
+
299
+ log(
300
+ 'createClientTaskThread: queried %d thread messages, %d main messages',
301
+ threadMessages.length,
302
+ messages.length,
303
+ );
304
+
305
+ // 4. Return Thread, userMessageId, threadMessages and messages
306
+ return {
307
+ messages,
308
+ startedAt,
309
+ success: true,
310
+ threadId: thread.id,
311
+ threadMessages,
312
+ userMessageId: userMessage.id,
313
+ };
314
+ } catch (error: any) {
315
+ log('createClientTaskThread failed: %O', error);
316
+
317
+ if (error instanceof TRPCError) {
318
+ throw error;
319
+ }
320
+
321
+ throw new TRPCError({
322
+ cause: error,
323
+ code: 'INTERNAL_SERVER_ERROR',
324
+ message: `Failed to create client task thread: ${error.message}`,
325
+ });
326
+ }
327
+ }),
328
+
187
329
  createOperation: aiAgentProcedure
188
330
  .input(CreateAgentOperationSchema)
189
331
  .mutation(async ({ input, ctx }) => {
@@ -846,4 +988,100 @@ export const aiAgentRouter = router({
846
988
  timestamp: new Date().toISOString(),
847
989
  };
848
990
  }),
991
+
992
+ /**
993
+ * Update Thread status after client-side task execution completes
994
+ *
995
+ * This endpoint is called by desktop client after task execution finishes.
996
+ * It updates the Thread status and metadata similar to server-side completion.
997
+ */
998
+ updateClientTaskThreadStatus: aiAgentProcedure
999
+ .input(UpdateClientTaskThreadStatusSchema)
1000
+ .mutation(async ({ input, ctx }) => {
1001
+ const { threadId, completionReason, error, resultContent, metadata } = input;
1002
+
1003
+ log('updateClientTaskThreadStatus: threadId=%s, reason=%s', threadId, completionReason);
1004
+
1005
+ try {
1006
+ // Find thread
1007
+ const thread = await ctx.threadModel.findById(threadId);
1008
+ if (!thread) {
1009
+ throw new TRPCError({
1010
+ code: 'NOT_FOUND',
1011
+ message: 'Thread not found',
1012
+ });
1013
+ }
1014
+
1015
+ const completedAt = new Date().toISOString();
1016
+ const startedAt = thread.metadata?.startedAt;
1017
+ const duration = startedAt ? Date.now() - new Date(startedAt).getTime() : undefined;
1018
+
1019
+ // Determine thread status based on completion reason
1020
+ let status: ThreadStatus;
1021
+ switch (completionReason) {
1022
+ case 'done': {
1023
+ status = ThreadStatus.Completed;
1024
+ break;
1025
+ }
1026
+ case 'error': {
1027
+ status = ThreadStatus.Failed;
1028
+ break;
1029
+ }
1030
+ case 'interrupted': {
1031
+ status = ThreadStatus.Cancel;
1032
+ break;
1033
+ }
1034
+ default: {
1035
+ status = ThreadStatus.Completed;
1036
+ }
1037
+ }
1038
+
1039
+ // Update Thread metadata and status
1040
+ await ctx.threadModel.update(threadId, {
1041
+ metadata: {
1042
+ ...thread.metadata,
1043
+ completedAt,
1044
+ duration,
1045
+ error: error || undefined,
1046
+ totalCost: metadata?.totalCost,
1047
+ totalMessages: metadata?.totalMessages,
1048
+ totalSteps: metadata?.totalSteps,
1049
+ totalTokens: metadata?.totalTokens,
1050
+ totalToolCalls: metadata?.totalToolCalls,
1051
+ },
1052
+ status,
1053
+ });
1054
+
1055
+ // Update task message (sourceMessageId) with result content if provided
1056
+ if (resultContent && thread.sourceMessageId) {
1057
+ await ctx.messageModel.update(thread.sourceMessageId, {
1058
+ content: resultContent,
1059
+ });
1060
+ log(
1061
+ 'updateClientTaskThreadStatus: updated task message %s with result',
1062
+ thread.sourceMessageId,
1063
+ );
1064
+ }
1065
+
1066
+ log('updateClientTaskThreadStatus: thread %s completed with status %s', threadId, status);
1067
+
1068
+ return {
1069
+ status,
1070
+ success: true,
1071
+ threadId,
1072
+ };
1073
+ } catch (error: any) {
1074
+ log('updateClientTaskThreadStatus failed: %O', error);
1075
+
1076
+ if (error instanceof TRPCError) {
1077
+ throw error;
1078
+ }
1079
+
1080
+ throw new TRPCError({
1081
+ cause: error,
1082
+ code: 'INTERNAL_SERVER_ERROR',
1083
+ message: `Failed to update client task thread status: ${error.message}`,
1084
+ });
1085
+ }
1086
+ }),
849
1087
  });
@@ -21,6 +21,7 @@ const threadProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
21
21
  export const threadRouter = router({
22
22
  createThread: threadProcedure.input(createThreadSchema).mutation(async ({ input, ctx }) => {
23
23
  const thread = await ctx.threadModel.create({
24
+ metadata: input.metadata,
24
25
  parentThreadId: input.parentThreadId,
25
26
  sourceMessageId: input.sourceMessageId,
26
27
  title: input.title,
@@ -38,6 +39,7 @@ export const threadRouter = router({
38
39
  )
39
40
  .mutation(async ({ input, ctx }) => {
40
41
  const thread = await ctx.threadModel.create({
42
+ metadata: input.metadata,
41
43
  parentThreadId: input.parentThreadId,
42
44
  sourceMessageId: input.sourceMessageId,
43
45
  title: input.message.content.slice(0, 20),
@@ -76,6 +76,12 @@ export const topicRouter = router({
76
76
  return ctx.topicModel.batchDelete(input.ids);
77
77
  }),
78
78
 
79
+ batchDeleteByAgentId: topicProcedure
80
+ .input(z.object({ agentId: z.string() }))
81
+ .mutation(async ({ input, ctx }) => {
82
+ return ctx.topicModel.batchDeleteByAgentId(input.agentId);
83
+ }),
84
+
79
85
  batchDeleteBySessionId: topicProcedure
80
86
  .input(
81
87
  z.object({
@@ -1,3 +1,6 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
1
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
5
 
3
6
  import { AgentRuntimeService } from './AgentRuntimeService';
@@ -166,7 +169,7 @@ describe('AgentRuntimeService', () => {
166
169
  it('should initialize with default base URL', () => {
167
170
  delete process.env.AGENT_RUNTIME_BASE_URL;
168
171
  const newService = new AgentRuntimeService(mockDb, mockUserId);
169
- expect((newService as any).baseURL).toBe('http://localhost:3010/api/agent');
172
+ expect((newService as any).baseURL).toBe('http://localhost:3210/api/agent');
170
173
  });
171
174
 
172
175
  it('should initialize with custom base URL from environment', () => {
@@ -10,6 +10,7 @@ import urlJoin from 'url-join';
10
10
 
11
11
  import { MessageModel } from '@/database/models/message';
12
12
  import { type LobeChatDatabase } from '@/database/type';
13
+ import { appEnv } from '@/envs/app';
13
14
  import {
14
15
  AgentRuntimeCoordinator,
15
16
  type AgentRuntimeCoordinatorOptions,
@@ -126,7 +127,7 @@ export class AgentRuntimeService {
126
127
  private stepCallbacks: Map<string, StepLifecycleCallbacks> = new Map();
127
128
  private get baseURL() {
128
129
  const baseUrl =
129
- process.env.AGENT_RUNTIME_BASE_URL || process.env.APP_URL || 'http://localhost:3010';
130
+ process.env.AGENT_RUNTIME_BASE_URL || appEnv.APP_URL || 'http://localhost:3010';
130
131
 
131
132
  return urlJoin(baseUrl, '/api/agent');
132
133
  }
@@ -293,6 +293,7 @@ describe('MessageService', () => {
293
293
  current: 0,
294
294
  groupId: undefined,
295
295
  pageSize: 9999,
296
+ threadId: undefined,
296
297
  topicId: undefined,
297
298
  },
298
299
  expect.objectContaining({
@@ -327,6 +328,42 @@ describe('MessageService', () => {
327
328
  current: 0,
328
329
  groupId: 'group-1',
329
330
  pageSize: 9999,
331
+ threadId: undefined,
332
+ topicId: 'topic-1',
333
+ },
334
+ expect.objectContaining({
335
+ postProcessUrl: expect.any(Function),
336
+ }),
337
+ );
338
+ expect(result.id).toBe('msg-1');
339
+ expect(result.messages).toEqual(mockMessages);
340
+ });
341
+
342
+ it('should create message with threadId and query thread messages', async () => {
343
+ const params = {
344
+ agentId: 'agent-1',
345
+ content: 'Hello in thread',
346
+ groupId: 'group-1',
347
+ role: 'user' as const,
348
+ threadId: 'thread-1',
349
+ topicId: 'topic-1',
350
+ };
351
+ const createdMessage = { id: 'msg-1', ...params };
352
+ const mockMessages = [createdMessage];
353
+
354
+ vi.mocked(mockMessageModel.create).mockResolvedValue(createdMessage as any);
355
+ vi.mocked(mockMessageModel.query).mockResolvedValue(mockMessages as any);
356
+
357
+ const result = await messageService.createMessage(params as any);
358
+
359
+ expect(mockMessageModel.create).toHaveBeenCalledWith(params);
360
+ expect(mockMessageModel.query).toHaveBeenCalledWith(
361
+ {
362
+ agentId: 'agent-1',
363
+ current: 0,
364
+ groupId: 'group-1',
365
+ pageSize: 9999,
366
+ threadId: 'thread-1',
330
367
  topicId: 'topic-1',
331
368
  },
332
369
  expect.objectContaining({
@@ -1,5 +1,9 @@
1
1
  import { type LobeChatDatabase } from '@lobechat/database';
2
- import { type CreateMessageParams, type UIChatMessage, type UpdateMessageParams } from '@lobechat/types';
2
+ import {
3
+ type CreateMessageParams,
4
+ type UIChatMessage,
5
+ type UpdateMessageParams,
6
+ } from '@lobechat/types';
3
7
 
4
8
  import { MessageModel } from '@/database/models/message';
5
9
 
@@ -95,6 +99,7 @@ export class MessageService {
95
99
  current: 0,
96
100
  groupId: params.groupId,
97
101
  pageSize: 9999,
102
+ threadId: params.threadId,
98
103
  topicId: params.topicId,
99
104
  },
100
105
  {
@@ -40,6 +40,38 @@ export interface InterruptTaskParams {
40
40
  threadId?: string;
41
41
  }
42
42
 
43
+ /**
44
+ * Parameters for createClientTaskThread
45
+ * Creates a Thread for client-side task execution (desktop only)
46
+ */
47
+ export interface CreateClientTaskThreadParams {
48
+ agentId: string;
49
+ groupId?: string;
50
+ /** Initial user message content (task instruction) */
51
+ instruction: string;
52
+ parentMessageId: string;
53
+ title?: string;
54
+ topicId: string;
55
+ }
56
+
57
+ /**
58
+ * Parameters for updateClientTaskThreadStatus
59
+ * Updates Thread status after client-side execution completes
60
+ */
61
+ export interface UpdateClientTaskThreadStatusParams {
62
+ completionReason: 'done' | 'error' | 'interrupted';
63
+ error?: string;
64
+ metadata?: {
65
+ totalCost?: number;
66
+ totalMessages?: number;
67
+ totalSteps?: number;
68
+ totalTokens?: number;
69
+ totalToolCalls?: number;
70
+ };
71
+ resultContent?: string;
72
+ threadId: string;
73
+ }
74
+
43
75
  class AiAgentService {
44
76
  /**
45
77
  * Execute a single Agent task
@@ -72,6 +104,25 @@ class AiAgentService {
72
104
  async interruptTask(params: InterruptTaskParams) {
73
105
  return await lambdaClient.aiAgent.interruptTask.mutate(params);
74
106
  }
107
+
108
+ /**
109
+ * Create Thread for client-side task execution (desktop only)
110
+ *
111
+ * This method is called when runInClient=true on desktop client.
112
+ * It creates the Thread but does NOT execute the task - execution happens locally.
113
+ */
114
+ async createClientTaskThread(params: CreateClientTaskThreadParams) {
115
+ return await lambdaClient.aiAgent.createClientTaskThread.mutate(params);
116
+ }
117
+
118
+ /**
119
+ * Update Thread status after client-side task execution completes
120
+ *
121
+ * This method is called by desktop client after task execution finishes.
122
+ */
123
+ async updateClientTaskThreadStatus(params: UpdateClientTaskThreadStatusParams) {
124
+ return await lambdaClient.aiAgent.updateClientTaskThreadStatus.mutate(params);
125
+ }
75
126
  }
76
127
 
77
128
  export const aiAgentService = new AiAgentService();
@@ -109,6 +109,10 @@ export class TopicService {
109
109
  return lambdaClient.topic.batchDeleteBySessionId.mutate({ id: this.toDbSessionId(sessionId) });
110
110
  };
111
111
 
112
+ removeTopicsByAgentId = (agentId: string) => {
113
+ return lambdaClient.topic.batchDeleteByAgentId.mutate({ agentId });
114
+ };
115
+
112
116
  batchRemoveTopics = (topics: string[]) => {
113
117
  return lambdaClient.topic.batchDelete.mutate({ ids: topics });
114
118
  };