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;