feat(agent-instance-chat): 实现用户与自己的 OpenClaw 智能体直接对话功能

## 功能概述
用户可在「我的智能体」页面点击运行中的 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-09 19:30:38 -07:00
parent 647df6e42f
commit 8865985019
10 changed files with 466 additions and 12 deletions

View File

@ -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';

View File

@ -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<Map<String, dynamic>> createTask({
required String sessionId,
required String message,
List<Map<String, dynamic>>? 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<String, dynamic>;
}
@override
Future<List<SessionSummary>> listSessions() async {
final response = await _dio.get(ApiEndpoints.instanceSessions(instanceId));
final data = response.data;
List<dynamic> 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<String, dynamic>))
.toList();
list.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return list;
}
// Delegated operations
@override
Future<List<ChatMessageModel>> getSessionHistory(String sessionId) =>
_delegate.getSessionHistory(sessionId);
@override
Future<List<ChatMessageModel>> getSessionMessages(String sessionId) =>
_delegate.getSessionMessages(sessionId);
@override
Future<void> deleteSession(String sessionId) =>
_delegate.deleteSession(sessionId);
@override
Future<void> approveCommand(String taskId) =>
_delegate.approveCommand(taskId);
@override
Future<void> rejectCommand(String taskId, {String? reason}) =>
_delegate.rejectCommand(taskId, reason: reason);
@override
Future<void> cancelTask(String taskId) =>
_delegate.cancelTask(taskId);
@override
Future<Map<String, dynamic>> injectMessage({
required String taskId,
required String message,
}) =>
_delegate.injectMessage(taskId: taskId, message: message);
@override
Future<void> confirmStandingOrder(
String sessionId,
Map<String, dynamic> draft,
) =>
_delegate.confirmStandingOrder(sessionId, draft);
@override
Future<String> transcribeAudio({required String audioPath}) =>
_delegate.transcribeAudio(audioPath: audioPath);
@override
Future<Map<String, dynamic>> sendVoiceMessage({
required String sessionId,
required String audioPath,
String language = 'zh',
}) =>
_delegate.sendVoiceMessage(
sessionId: sessionId,
audioPath: audioPath,
language: language,
);
}

View File

@ -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),
);
}
}

View File

@ -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<ChatPage> createState() => _ChatPageState();
@ -542,9 +546,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
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<ChatPage> {
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),
],
),

View File

@ -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<void> _handleDismiss(
BuildContext context, WidgetRef ref, AgentInstance instance) async {
final confirmed = await showDialog<bool>(
@ -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 {
],
],
),
),
),
);
}
}

View File

@ -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:

View File

@ -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;

View File

@ -20,4 +20,13 @@ export class SessionRepository extends TenantAwareRepository<AgentSession> {
repo.find({ where: { status } as any }),
);
}
async findByInstanceId(tenantId: string, agentInstanceId: string): Promise<AgentSession[]> {
return this.withRepository((repo) =>
repo.find({
where: { tenantId, agentInstanceId } as any,
order: { updatedAt: 'DESC' },
}),
);
}
}

View File

@ -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;
}
}

View File

@ -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;