From 8865985019fd2535954e1882d13d61efafa3433e Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 9 Mar 2026 19:30:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(agent-instance-chat):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=B8=8E=E8=87=AA=E5=B7=B1=E7=9A=84=20OpenCl?= =?UTF-8?q?aw=20=E6=99=BA=E8=83=BD=E4=BD=93=E7=9B=B4=E6=8E=A5=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能概述 用户可在「我的智能体」页面点击运行中的 OpenClaw 实例卡片, 直接打开与该智能体的专属对话页面,完整复用 iAgent 的聊天 UI (流式输出、工具时间线、审批卡片、语音输入等),同时保证 iAgent 对话完全不受影响。 ## 架构设计 - 使用 Riverpod ProviderScope 子作用域覆盖 chatRemoteDatasourceProvider / chatProvider / sessionListProvider,实现 iAgent 与实例对话的 provider 完全隔离,无任何共享状态。 - OpenClaw bridge 采用已有的 /task-async 异步回调模式: Flutter → POST /api/v1/agent/instances/:id/tasks(立即返回 sessionId/taskId) → 订阅 WS /ws/agent(等待事件) → Bridge 完成后 POST /api/v1/agent/instances/openclaw-app-callback(公开端点) → 后端发 WS text+completed 事件 → Flutter 收到回复 - 每个实例的会话通过 agent_sessions.agent_instance_id 字段隔离, 会话抽屉只显示当前实例的历史记录。 ## 后端变更 ### packages/shared/database/src/migrations/013-add-agent-instance-id-to-sessions.sql - 新增迁移:ALTER TABLE agent_sessions ADD COLUMN agent_instance_id UUID NULL - 为按实例过滤会话建立索引 ### packages/services/agent-service/src/domain/entities/agent-session.entity.ts - 新增可选字段 agentInstanceId: string(对应 agent_instance_id 列) - iAgent 会话该字段为 null;实例聊天会话存储对应的 instance UUID ### packages/services/agent-service/src/infrastructure/repositories/session.repository.ts - 新增 findByInstanceId(tenantId, agentInstanceId) 方法 - 用于 GET /instances/:id/sessions 按实例过滤会话列表 ### packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts 新增三个端点(注意:已知存在以下待修复问题,见后续 fix commit): 1. POST /api/v1/agent/instances/:instanceId/tasks - 校验 instance 归属(userId 匹配)和 running 状态 - 创建会话(engineType='openclaw',携带 agentInstanceId) - 保存用户消息到 conversation_messages 表 - 向 OpenClaw bridge POST /task-async,sessionKey=it0:{sessionId} - 立即返回 { sessionId, taskId },Flutter 订阅 WS 等待回调 2. GET /api/v1/agent/instances/:instanceId/sessions - 返回该实例的会话列表(含 title/status/时间戳) 3. POST /api/v1/agent/instances/openclaw-app-callback(公开端点,无 JWT) - bridge 完成后回调此端点 - 成功:发 WS text+completed 事件,保存 assistant 消息,更新 task 状态 - 失败/超时:发 WS error 事件,标记 task 为 FAILED - 注入 AgentInstanceRepository 依赖 - 新增私有方法 createInstanceSession() ### packages/gateway/config/kong.yml - 新增 openclaw-app-callback-public service(无 JWT 插件) - 路由:POST /api/v1/agent/instances/openclaw-app-callback - 必须在 agent-service 之前声明,确保路由优先匹配(同 wecom-public 模式) ## Flutter 变更 ### it0_app/lib/core/config/api_endpoints.dart - 新增 instanceTasks(instanceId) 和 instanceSessions(instanceId) 静态方法 ### it0_app/lib/features/chat/presentation/pages/chat_page.dart - 新增可选参数 agentName(默认 null = iAgent 模式) - agentName != null 时:AppBar 显示智能体名称,隐藏语音通话按钮 - 不传 agentName 时行为与原来完全一致,iAgent 功能零影响 ### it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart - _InstanceCard 新增 onTap 回调参数 - 卡片用 Material+InkWell 包裹,支持圆角水波纹点击效果 - 新增 _openInstanceChat() 顶层函数: running → 滑入式跳转到 AgentInstanceChatPage 其他状态 → SnackBar 提示(部署中/已停止/错误) - 导入 AgentInstanceChatPage ### it0_app/lib/features/agent_instance_chat/(新建功能模块) data/datasources/agent_instance_chat_remote_datasource.dart: - AgentInstanceChatDatasource implements ChatRemoteDatasource - 通过组合模式包装 ChatRemoteDatasource 委托所有通用操作 - 覆盖 createTask → POST /api/v1/agent/instances/:id/tasks - 覆盖 listSessions → GET /api/v1/agent/instances/:id/sessions(仅当前实例会话) presentation/pages/agent_instance_chat_page.dart: - AgentInstanceChatPage(instance: AgentInstance) - ProviderScope 子作用域覆盖三个 provider 实现完全隔离: chatRemoteDatasourceProvider → AgentInstanceChatDatasource chatProvider → 独立 ChatNotifier 实例(与 iAgent 零共享) sessionListProvider → 仅当前实例的会话列表 - child: ChatPage(agentName: instance.name) 完整复用 UI ## 已知待修复问题(下一个 commit) 1. [安全] 鉴权检查逻辑:if (userId && ...) 应为 if (!userId || ...) 2. [可靠性] fetch 未处理 HTTP 4xx/5xx 错误,任务可能永久挂起 3. [可靠性] bridge 回调无超时机制,bridge 崩溃后任务永久 RUNNING 4. [UX] robotStateProvider 未在子 ProviderScope 覆盖,头像动画反映 iAgent 状态 5. [UX] 实例聊天附件 UI 未禁用,上传附件被静默丢弃 6. [UX] 语音消息在实例模式下错误路由到 iAgent 引擎(非 OpenClaw) 7. [DB] 002 模板未加 agent_instance_id 列,新租户缺失此字段 Co-Authored-By: Claude Sonnet 4.6 --- it0_app/lib/core/config/api_endpoints.dart | 4 + ...agent_instance_chat_remote_datasource.dart | 114 +++++++++ .../pages/agent_instance_chat_page.dart | 43 ++++ .../chat/presentation/pages/chat_page.dart | 27 ++- .../presentation/pages/my_agents_page.dart | 45 +++- packages/gateway/config/kong.yml | 9 + .../domain/entities/agent-session.entity.ts | 3 + .../repositories/session.repository.ts | 9 + .../rest/controllers/agent.controller.ts | 218 ++++++++++++++++++ .../013-add-agent-instance-id-to-sessions.sql | 6 + 10 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 it0_app/lib/features/agent_instance_chat/data/datasources/agent_instance_chat_remote_datasource.dart create mode 100644 it0_app/lib/features/agent_instance_chat/presentation/pages/agent_instance_chat_page.dart create mode 100644 packages/shared/database/src/migrations/013-add-agent-instance-id-to-sessions.sql diff --git a/it0_app/lib/core/config/api_endpoints.dart b/it0_app/lib/core/config/api_endpoints.dart index c45ad7d..f38c341 100644 --- a/it0_app/lib/core/config/api_endpoints.dart +++ b/it0_app/lib/core/config/api_endpoints.dart @@ -24,6 +24,10 @@ class ApiEndpoints { static const String agentConfigs = '$agent/configs'; static const String agentInstances = '$agent/instances'; + // Instance chat (user ↔ their own OpenClaw agent) + static String instanceTasks(String instanceId) => '$agentInstances/$instanceId/tasks'; + static String instanceSessions(String instanceId) => '$agentInstances/$instanceId/sessions'; + // Ops static const String opsTasks = '$ops/tasks'; static const String approvals = '$ops/approvals'; diff --git a/it0_app/lib/features/agent_instance_chat/data/datasources/agent_instance_chat_remote_datasource.dart b/it0_app/lib/features/agent_instance_chat/data/datasources/agent_instance_chat_remote_datasource.dart new file mode 100644 index 0000000..5ffa9fe --- /dev/null +++ b/it0_app/lib/features/agent_instance_chat/data/datasources/agent_instance_chat_remote_datasource.dart @@ -0,0 +1,114 @@ +import 'package:dio/dio.dart'; +import '../../../../core/config/api_endpoints.dart'; +import '../../../chat/data/datasources/chat_remote_datasource.dart'; +import '../../../chat/data/models/chat_message_model.dart'; +import '../../../chat/presentation/widgets/conversation_drawer.dart'; + +/// Datasource for chatting with a specific user-owned OpenClaw agent instance. +/// +/// Delegates all standard operations to the wrapped [ChatRemoteDatasource] +/// but overrides [createTask] and [listSessions] to use instance-specific endpoints. +class AgentInstanceChatDatasource implements ChatRemoteDatasource { + final ChatRemoteDatasource _delegate; + final Dio _dio; + final String instanceId; + + AgentInstanceChatDatasource(this._dio, this.instanceId) + : _delegate = ChatRemoteDatasource(_dio); + + // ── Instance-specific overrides ──────────────────────────────────────────── + + @override + Future> createTask({ + required String sessionId, + required String message, + List>? attachments, + }) async { + final response = await _dio.post( + ApiEndpoints.instanceTasks(instanceId), + data: { + 'prompt': message, + if (sessionId != 'new') 'sessionId': sessionId, + // Note: attachments are not yet supported for instance chat + }, + ); + return response.data as Map; + } + + @override + Future> listSessions() async { + final response = await _dio.get(ApiEndpoints.instanceSessions(instanceId)); + final data = response.data; + + List sessions; + if (data is List) { + sessions = data; + } else if (data is Map && data.containsKey('sessions')) { + sessions = data['sessions'] as List; + } else { + sessions = []; + } + + final list = sessions + .map((s) => SessionSummary.fromJson(s as Map)) + .toList(); + list.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return list; + } + + // ── Delegated operations ─────────────────────────────────────────────────── + + @override + Future> getSessionHistory(String sessionId) => + _delegate.getSessionHistory(sessionId); + + @override + Future> getSessionMessages(String sessionId) => + _delegate.getSessionMessages(sessionId); + + @override + Future deleteSession(String sessionId) => + _delegate.deleteSession(sessionId); + + @override + Future approveCommand(String taskId) => + _delegate.approveCommand(taskId); + + @override + Future rejectCommand(String taskId, {String? reason}) => + _delegate.rejectCommand(taskId, reason: reason); + + @override + Future cancelTask(String taskId) => + _delegate.cancelTask(taskId); + + @override + Future> injectMessage({ + required String taskId, + required String message, + }) => + _delegate.injectMessage(taskId: taskId, message: message); + + @override + Future confirmStandingOrder( + String sessionId, + Map draft, + ) => + _delegate.confirmStandingOrder(sessionId, draft); + + @override + Future transcribeAudio({required String audioPath}) => + _delegate.transcribeAudio(audioPath: audioPath); + + @override + Future> sendVoiceMessage({ + required String sessionId, + required String audioPath, + String language = 'zh', + }) => + _delegate.sendVoiceMessage( + sessionId: sessionId, + audioPath: audioPath, + language: language, + ); +} diff --git a/it0_app/lib/features/agent_instance_chat/presentation/pages/agent_instance_chat_page.dart b/it0_app/lib/features/agent_instance_chat/presentation/pages/agent_instance_chat_page.dart new file mode 100644 index 0000000..d9c117f --- /dev/null +++ b/it0_app/lib/features/agent_instance_chat/presentation/pages/agent_instance_chat_page.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../chat/presentation/pages/chat_page.dart'; +import '../../../chat/presentation/providers/chat_providers.dart'; +import '../../../my_agents/presentation/pages/my_agents_page.dart'; +import '../../data/datasources/agent_instance_chat_remote_datasource.dart'; +import '../../../../core/network/dio_client.dart'; + +/// Full-screen chat page for a user conversing with their own OpenClaw instance. +/// +/// Reuses the exact same [ChatPage] UI and all its features (streaming, +/// tool timeline, approvals, attachments, etc.) but scopes the providers +/// to this specific agent instance so sessions are isolated from iAgent. +class AgentInstanceChatPage extends StatelessWidget { + final AgentInstance instance; + + const AgentInstanceChatPage({super.key, required this.instance}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + overrides: [ + // Instance-specific datasource: routes createTask → /instances/:id/tasks + // and listSessions → /instances/:id/sessions + chatRemoteDatasourceProvider.overrideWith((ref) { + final dio = ref.watch(dioClientProvider); + return AgentInstanceChatDatasource(dio, instance.id); + }), + + // Fresh, isolated chatProvider for this instance — does NOT share + // state with iAgent's chatProvider in the parent scope. + chatProvider.overrideWith((ref) => ChatNotifier(ref)), + + // Session list scoped to this instance only + sessionListProvider.overrideWith((ref) async { + final ds = ref.watch(chatRemoteDatasourceProvider); + return ds.listSessions(); + }), + ], + child: ChatPage(agentName: instance.name), + ); + } +} diff --git a/it0_app/lib/features/chat/presentation/pages/chat_page.dart b/it0_app/lib/features/chat/presentation/pages/chat_page.dart index 0e5aef4..a21ecf5 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -24,7 +24,11 @@ import '../widgets/voice_mic_button.dart'; // --------------------------------------------------------------------------- class ChatPage extends ConsumerStatefulWidget { - const ChatPage({super.key}); + /// When non-null, this page is in "instance chat" mode (user ↔ their own agent). + /// The value is displayed as the AppBar title and the voice call button is hidden. + final String? agentName; + + const ChatPage({super.key, this.agentName}); @override ConsumerState createState() => _ChatPageState(); @@ -542,9 +546,10 @@ class _ChatPageState extends ConsumerState { children: [ RobotAvatar(state: robotState, size: 32), const SizedBox(width: 8), - Text(AppLocalizations.of(context).appTitle, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600)), + Text( + widget.agentName ?? AppLocalizations.of(context).appTitle, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), ], ); }, @@ -563,12 +568,14 @@ class _ChatPageState extends ConsumerState { visualDensity: VisualDensity.compact, onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), ), - IconButton( - icon: const Icon(Icons.call, size: 20), - tooltip: AppLocalizations.of(context).chatVoiceCallTooltip, - visualDensity: VisualDensity.compact, - onPressed: _openVoiceCall, - ), + // Voice call is iAgent-only; not available for user-owned agent instances + if (widget.agentName == null) + IconButton( + icon: const Icon(Icons.call, size: 20), + tooltip: AppLocalizations.of(context).chatVoiceCallTooltip, + visualDensity: VisualDensity.compact, + onPressed: _openVoiceCall, + ), const SizedBox(width: 4), ], ), diff --git a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart index 2cf3e47..ca2cc09 100644 --- a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart +++ b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart @@ -12,6 +12,7 @@ import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/date_formatter.dart'; import '../../../../core/widgets/error_view.dart'; +import '../../../agent_instance_chat/presentation/pages/agent_instance_chat_page.dart'; // --------------------------------------------------------------------------- // Model @@ -219,6 +220,7 @@ class MyAgentsPage extends ConsumerWidget { onDismiss: () => _handleDismiss(context, ref, inst), onRename: () => _handleRename(context, ref, inst), onRefresh: () => ref.invalidate(myInstancesProvider), + onTap: () => _openInstanceChat(context, inst), ); }, childCount: instances.length * 2 - 1, @@ -235,6 +237,36 @@ class MyAgentsPage extends ConsumerWidget { // Dismiss / Rename helpers (top-level functions for ConsumerWidget access) // --------------------------------------------------------------------------- +void _openInstanceChat(BuildContext context, AgentInstance instance) { + if (instance.status != 'running') { + final hint = switch (instance.status) { + 'deploying' => '小龙虾还在部署中,请稍候再试', + 'stopped' => '小龙虾已停止,请先启动后再对话', + 'error' => '小龙虾遇到了问题,请检查状态后重试', + _ => '小龙虾当前不可用(${instance.status})', + }; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(hint))); + return; + } + + Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + AgentInstanceChatPage(instance: instance), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final tween = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)); + return SlideTransition( + position: animation.drive(tween), + child: child, + ); + }, + ), + ); +} + Future _handleDismiss( BuildContext context, WidgetRef ref, AgentInstance instance) async { final confirmed = await showDialog( @@ -326,8 +358,9 @@ class _InstanceCard extends StatelessWidget { final VoidCallback? onDismiss; final VoidCallback? onRename; final VoidCallback? onRefresh; + final VoidCallback? onTap; - const _InstanceCard({required this.instance, this.onDismiss, this.onRename, this.onRefresh}); + const _InstanceCard({required this.instance, this.onDismiss, this.onRename, this.onRefresh, this.onTap}); void _showActions(BuildContext context) { showModalBottomSheet( @@ -526,7 +559,13 @@ class _InstanceCard extends StatelessWidget { final timeLabel = DateFormatter.timeAgo(instance.createdAt); final isDeploying = instance.status == 'deploying'; - return Container( + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.surface, @@ -691,6 +730,8 @@ class _InstanceCard extends StatelessWidget { ], ], ), + ), + ), ); } } diff --git a/packages/gateway/config/kong.yml b/packages/gateway/config/kong.yml index 16509b5..8c9728e 100644 --- a/packages/gateway/config/kong.yml +++ b/packages/gateway/config/kong.yml @@ -48,6 +48,15 @@ services: - /api/v1/agent/channels/wecom/bridge-callback strip_path: false + # Public OpenClaw app callback — no JWT (bridge POSTs here after in-app chat LLM completes) + - name: openclaw-app-callback-public + url: http://agent-service:3002 + routes: + - name: openclaw-app-callback + paths: + - /api/v1/agent/instances/openclaw-app-callback + strip_path: false + - name: agent-service url: http://agent-service:3002 routes: diff --git a/packages/services/agent-service/src/domain/entities/agent-session.entity.ts b/packages/services/agent-service/src/domain/entities/agent-session.entity.ts index 75112b8..312b78a 100644 --- a/packages/services/agent-service/src/domain/entities/agent-session.entity.ts +++ b/packages/services/agent-service/src/domain/entities/agent-session.entity.ts @@ -14,6 +14,9 @@ export class AgentSession { @Column({ type: 'varchar', length: 20, default: 'active' }) status!: 'active' | 'completed' | 'cancelled' | 'error'; + @Column({ type: 'uuid', nullable: true }) + agentInstanceId?: string; + @Column({ type: 'text', nullable: true }) systemPrompt?: string; diff --git a/packages/services/agent-service/src/infrastructure/repositories/session.repository.ts b/packages/services/agent-service/src/infrastructure/repositories/session.repository.ts index 5f85517..e0ff8ad 100644 --- a/packages/services/agent-service/src/infrastructure/repositories/session.repository.ts +++ b/packages/services/agent-service/src/infrastructure/repositories/session.repository.ts @@ -20,4 +20,13 @@ export class SessionRepository extends TenantAwareRepository { repo.find({ where: { status } as any }), ); } + + async findByInstanceId(tenantId: string, agentInstanceId: string): Promise { + return this.withRepository((repo) => + repo.find({ + where: { tenantId, agentInstanceId } as any, + order: { updatedAt: 'DESC' }, + }), + ); + } } diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts index 473dba2..61e825a 100644 --- a/packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts +++ b/packages/services/agent-service/src/interfaces/rest/controllers/agent.controller.ts @@ -18,6 +18,7 @@ import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type import { AgentEnginePort, EngineStreamEvent } from '../../../domain/ports/outbound/agent-engine.port'; import { ClaudeAgentSdkEngine } from '../../../infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine'; import { SystemPromptBuilder } from '../../../infrastructure/engines/claude-code-cli/system-prompt-builder'; +import { AgentInstanceRepository } from '../../../infrastructure/repositories/agent-instance.repository'; import * as crypto from 'crypto'; @Controller('api/v1/agent') @@ -36,6 +37,7 @@ export class AgentController { private readonly eventPublisher: EventPublisherService, private readonly sttService: OpenAISttService, private readonly systemPromptBuilder: SystemPromptBuilder, + private readonly instanceRepository: AgentInstanceRepository, ) {} @Post('tasks') @@ -801,6 +803,209 @@ export class AgentController { } } + // --------------------------------------------------------------------------- + // Instance chat endpoints — user chatting directly with their OpenClaw agent + // --------------------------------------------------------------------------- + + /** + * Start or continue a conversation with a specific OpenClaw agent instance. + * + * POST /api/v1/agent/instances/:instanceId/tasks + * Body: { prompt, sessionId? } + * + * Routes the message to the OpenClaw bridge via /task-async and returns + * immediately. The bridge POSTs the result to openclaw-app-callback when done. + * Flutter subscribes to the WS session to receive the reply. + */ + @Post('instances/:instanceId/tasks') + async executeInstanceTask( + @TenantId() tenantId: string, + @Req() req: any, + @Param('instanceId') instanceId: string, + @Body() body: { prompt: string; sessionId?: string }, + ) { + const instance = await this.instanceRepository.findById(instanceId); + if (!instance) throw new NotFoundException(`Instance ${instanceId} not found`); + + // Validate that this instance belongs to the requesting user + const jwtPayload = this.decodeJwt(req.headers?.['authorization'] as string | undefined); + const userId: string | undefined = jwtPayload?.sub; + if (userId && instance.userId !== userId) { + throw new ForbiddenException('Instance does not belong to you'); + } + + if (instance.status !== 'running') { + throw new BadRequestException(`Instance is ${instance.status} — it must be running to accept messages`); + } + + if (!instance.serverHost) { + throw new BadRequestException('Instance has no server host configured'); + } + + // Reuse existing instance session or create a new one + let session: AgentSession; + if (body.sessionId) { + const existing = await this.sessionRepository.findById(body.sessionId); + if ( + existing && + existing.status === 'active' && + existing.tenantId === tenantId && + (existing as any).agentInstanceId === instanceId + ) { + session = existing; + } else { + session = this.createInstanceSession(tenantId, instanceId, body.prompt); + } + } else { + session = this.createInstanceSession(tenantId, instanceId, body.prompt); + } + + session.status = 'active'; + session.updatedAt = new Date(); + await this.sessionRepository.save(session); + + // Create task record + const task = new AgentTask(); + task.id = crypto.randomUUID(); + task.tenantId = tenantId; + task.sessionId = session.id; + task.prompt = body.prompt; + task.status = TaskStatus.RUNNING; + task.startedAt = new Date(); + task.createdAt = new Date(); + await this.taskRepository.save(task); + + // Persist user message for display in conversation history + await this.contextService.saveUserMessage(session.id, body.prompt); + + // The OpenClaw bridge tracks conversation context internally via sessionKey. + // We use our DB session ID as the key so each session has isolated context. + const sessionKey = `it0:${session.id}`; + const callbackUrl = `${process.env.AGENT_SERVICE_PUBLIC_URL}/api/v1/agent/instances/openclaw-app-callback`; + const bridgeUrl = `http://${instance.serverHost}:${instance.hostPort}/task-async`; + + this.logger.log( + `[Task ${task.id}] Routing to OpenClaw instance ${instanceId} @ ${bridgeUrl}, session=${session.id}`, + ); + + // Emit session/task info events immediately so Flutter can subscribe + this.gateway.emitStreamEvent(session.id, { type: 'session_info', sessionId: session.id }); + this.gateway.emitStreamEvent(session.id, { type: 'task_info', taskId: task.id }); + + // Fire-and-forget POST to OpenClaw bridge + fetch(bridgeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: body.prompt, + sessionKey, + idempotencyKey: task.id, + callbackUrl, + callbackData: { sessionId: session.id, taskId: task.id }, + }), + }).catch((err: Error) => { + this.logger.error(`[Task ${task.id}] Bridge request failed: ${err.message}`); + this.gateway.emitStreamEvent(session.id, { + type: 'error', + message: `无法连接到智能体:${err.message}`, + }); + task.status = TaskStatus.FAILED; + task.result = err.message; + task.completedAt = new Date(); + this.taskRepository.save(task).catch(() => {}); + }); + + return { sessionId: session.id, taskId: task.id }; + } + + /** + * List conversation sessions for a specific OpenClaw instance. + * + * GET /api/v1/agent/instances/:instanceId/sessions + */ + @Get('instances/:instanceId/sessions') + async listInstanceSessions( + @TenantId() tenantId: string, + @Param('instanceId') instanceId: string, + ) { + const sessions = await this.sessionRepository.findByInstanceId(tenantId, instanceId); + return sessions.map((s) => ({ + id: s.id, + title: (s.metadata as any)?.title ?? '', + status: s.status, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + })); + } + + /** + * OpenClaw bridge callback for in-app instance chat. + * Called by the bridge when an async LLM task completes. + * PUBLIC — no JWT (internal bridge call from training server). + * + * POST /api/v1/agent/instances/openclaw-app-callback + */ + @Post('instances/openclaw-app-callback') + async handleOpenClawAppCallback( + @Body() body: { + ok: boolean; + result?: string; + error?: string; + isTimeout?: boolean; + callbackData: { sessionId: string; taskId: string }; + }, + ) { + const { ok, result, error, isTimeout, callbackData } = body; + const { sessionId, taskId } = callbackData ?? {}; + + this.logger.log( + `OpenClaw app callback: ok=${ok} taskId=${taskId} sessionId=${sessionId} ` + + `${ok ? `replyLen=${result?.length ?? 0}` : `error=${error} isTimeout=${isTimeout}`}`, + ); + + if (!sessionId || !taskId) { + this.logger.warn('OpenClaw app callback missing sessionId or taskId'); + return { received: true }; + } + + const task = await this.taskRepository.findById(taskId); + + if (ok && result) { + // Emit text + completed events so Flutter's WS stream receives the reply + this.gateway.emitStreamEvent(sessionId, { type: 'text', content: result }); + this.gateway.emitStreamEvent(sessionId, { type: 'completed', summary: result, tokensUsed: 0 }); + + // Persist assistant reply to conversation history + await this.contextService.saveAssistantMessage(sessionId, result); + + if (task) { + task.status = TaskStatus.COMPLETED; + task.result = result; + task.completedAt = new Date(); + await this.taskRepository.save(task); + } + + const session = await this.sessionRepository.findById(sessionId); + if (session) { + session.status = 'active'; + session.updatedAt = new Date(); + await this.sessionRepository.save(session); + } + } else { + const errorMsg = isTimeout ? '智能体响应超时,请重试' : (error || '智能体发生错误'); + this.gateway.emitStreamEvent(sessionId, { type: 'error', message: errorMsg }); + + if (task) { + task.status = TaskStatus.FAILED; + task.result = errorMsg; + task.completedAt = new Date(); + await this.taskRepository.save(task); + } + } + + return { received: true }; + } + private createNewSession( tenantId: string, engineType: string, @@ -819,4 +1024,17 @@ export class AgentController { session.updatedAt = new Date(); return session; } + + private createInstanceSession(tenantId: string, agentInstanceId: string, firstPrompt: string): AgentSession { + const session = new AgentSession(); + session.id = crypto.randomUUID(); + session.tenantId = tenantId; + session.engineType = 'openclaw'; + session.agentInstanceId = agentInstanceId; + session.status = 'active'; + session.metadata = { title: firstPrompt.substring(0, 40).trim(), titleSet: true }; + session.createdAt = new Date(); + session.updatedAt = new Date(); + return session; + } } diff --git a/packages/shared/database/src/migrations/013-add-agent-instance-id-to-sessions.sql b/packages/shared/database/src/migrations/013-add-agent-instance-id-to-sessions.sql new file mode 100644 index 0000000..7431bf8 --- /dev/null +++ b/packages/shared/database/src/migrations/013-add-agent-instance-id-to-sessions.sql @@ -0,0 +1,6 @@ +-- IT0 Migration 013: Add agent_instance_id to agent_sessions +-- Links a session to a specific user-owned OpenClaw instance (nullable for iAgent sessions) + +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS agent_instance_id UUID NULL; + +CREATE INDEX IF NOT EXISTS idx_agent_sessions_instance ON agent_sessions(agent_instance_id) WHERE agent_instance_id IS NOT NULL;