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 index 5ffa9fe..e031ce9 100644 --- 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 @@ -100,15 +100,19 @@ class AgentInstanceChatDatasource implements ChatRemoteDatasource { Future transcribeAudio({required String audioPath}) => _delegate.transcribeAudio(audioPath: audioPath); + /// For instance chat, voice is handled by transcribing locally then routing + /// the text prompt through the instance-specific [createTask] endpoint. + /// This ensures voice messages reach the user's OpenClaw instance, not iAgent. @override Future> sendVoiceMessage({ required String sessionId, required String audioPath, String language = 'zh', - }) => - _delegate.sendVoiceMessage( - sessionId: sessionId, - audioPath: audioPath, - language: language, - ); + }) async { + final transcript = await _delegate.transcribeAudio(audioPath: audioPath); + return createTask( + sessionId: sessionId, + message: transcript, + ); + } } 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 index d9c117f..79dacc9 100644 --- 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 @@ -5,6 +5,8 @@ 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'; +import '../../../../core/widgets/floating_robot_fab.dart'; +import '../../../../core/widgets/robot_painter.dart'; /// Full-screen chat page for a user conversing with their own OpenClaw instance. /// @@ -36,6 +38,21 @@ class AgentInstanceChatPage extends StatelessWidget { final ds = ref.watch(chatRemoteDatasourceProvider); return ds.listSessions(); }), + + // Derived providers must also be overridden so they read the child-scope + // chatProvider instead of the parent's iAgent chatProvider. + agentStatusProvider.overrideWith( + (ref) => ref.watch(chatProvider).agentStatus, + ), + robotStateProvider.overrideWith((ref) { + return switch (ref.watch(chatProvider).agentStatus) { + AgentStatus.idle => RobotState.idle, + AgentStatus.thinking => RobotState.thinking, + AgentStatus.executing => RobotState.executing, + AgentStatus.awaitingApproval => RobotState.alert, + AgentStatus.error => RobotState.alert, + }; + }), ], 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 a21ecf5..ec43d28 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -720,10 +720,16 @@ class _ChatPageState extends ConsumerState { if (!isStreaming) Padding( padding: const EdgeInsets.only(left: 4), - child: IconButton( - icon: const Icon(Icons.add_circle_outline, size: 22), - tooltip: AppLocalizations.of(context).chatAddImageTooltip, - onPressed: isAwaitingApproval ? null : _showAttachmentOptions, + child: Tooltip( + message: widget.agentName != null + ? '附件功能暂不支持智能体对话' + : AppLocalizations.of(context).chatAddImageTooltip, + child: IconButton( + icon: const Icon(Icons.add_circle_outline, size: 22), + onPressed: (isAwaitingApproval || widget.agentName != null) + ? null + : _showAttachmentOptions, + ), ), ), Expanded( 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 61e825a..4cc8a05 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 @@ -26,6 +26,11 @@ export class AgentController { private readonly logger = new Logger(AgentController.name); /** Tracks running task promises so cancel/inject can await cleanup. */ private readonly runningTasks = new Map>(); + /** + * Tracks pending OpenClaw callback timers keyed by taskId. + * Cleared when the bridge callback arrives, preventing spurious timeout errors. + */ + private readonly pendingCallbackTimers = new Map(); constructor( private readonly engineRegistry: EngineRegistry, @@ -830,7 +835,9 @@ export class AgentController { // 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) { + // Strict ownership check: reject if userId cannot be resolved (JWT decode failure) + // or if the instance belongs to a different user. + if (!userId || instance.userId !== userId) { throw new ForbiddenException('Instance does not belong to you'); } @@ -892,10 +899,47 @@ export class AgentController { 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 + // Fire-and-forget POST to OpenClaw bridge. + // + // Reliability guarantees: + // 1. AbortController cuts the connection after BRIDGE_REQUEST_TIMEOUT_MS if the + // bridge never responds (network hang / bridge frozen). + // 2. HTTP-level errors (4xx / 5xx from bridge) are detected via `response.ok` + // and treated the same as network failures. + // 3. A separate callback-level timeout (CALLBACK_TIMEOUT_MS) fires if the bridge + // accepted the request but never POSTed the callback (e.g. bridge crashed + // mid-execution). After this deadline the task is marked FAILED and an error + // event is emitted so Flutter can show the user an error instead of spinning. + const BRIDGE_REQUEST_TIMEOUT_MS = 15_000; // 15 s to accept the request + const CALLBACK_TIMEOUT_MS = 150_000; // 2.5 min for the LLM to reply + + const abortController = new AbortController(); + const requestTimer = setTimeout(() => abortController.abort(), BRIDGE_REQUEST_TIMEOUT_MS); + + // Callback-level timeout: if the bridge never calls back, fail the task ourselves. + const callbackTimer = setTimeout(async () => { + this.logger.warn(`[Task ${task.id}] OpenClaw callback timeout after ${CALLBACK_TIMEOUT_MS}ms`); + this.gateway.emitStreamEvent(session.id, { + type: 'error', + message: '智能体响应超时,请稍后重试', + }); + const staleTask = await this.taskRepository.findById(task.id); + if (staleTask && staleTask.status === TaskStatus.RUNNING) { + staleTask.status = TaskStatus.FAILED; + staleTask.result = 'Callback timeout'; + staleTask.completedAt = new Date(); + await this.taskRepository.save(staleTask).catch(() => {}); + } + }, CALLBACK_TIMEOUT_MS); + // Unref so this timer doesn't keep the process alive if the server shuts down. + if ((callbackTimer as any).unref) (callbackTimer as any).unref(); + // Track timer so the callback handler can cancel it when the bridge replies in time. + this.pendingCallbackTimers.set(task.id, callbackTimer); + fetch(bridgeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + signal: abortController.signal, body: JSON.stringify({ prompt: body.prompt, sessionKey, @@ -903,12 +947,25 @@ export class AgentController { callbackUrl, callbackData: { sessionId: session.id, taskId: task.id }, }), - }).catch((err: Error) => { + }) + .then(async (res) => { + clearTimeout(requestTimer); + if (!res.ok) { + // HTTP 4xx / 5xx from the bridge — treat as failure + const body = await res.text().catch(() => ''); + throw new Error(`Bridge returned HTTP ${res.status}: ${body.slice(0, 200)}`); + } + this.logger.log(`[Task ${task.id}] Bridge accepted request (HTTP ${res.status}), awaiting callback`); + }) + .catch((err: Error) => { + clearTimeout(requestTimer); + clearTimeout(callbackTimer); + const isAbort = err.name === 'AbortError'; + const msg = isAbort + ? `连接智能体超时(${BRIDGE_REQUEST_TIMEOUT_MS / 1000}s),请确认智能体正在运行` + : `无法连接到智能体:${err.message}`; this.logger.error(`[Task ${task.id}] Bridge request failed: ${err.message}`); - this.gateway.emitStreamEvent(session.id, { - type: 'error', - message: `无法连接到智能体:${err.message}`, - }); + this.gateway.emitStreamEvent(session.id, { type: 'error', message: msg }); task.status = TaskStatus.FAILED; task.result = err.message; task.completedAt = new Date(); @@ -968,6 +1025,13 @@ export class AgentController { return { received: true }; } + // Cancel the local callback-timeout timer — the bridge replied in time. + const pendingTimer = this.pendingCallbackTimers.get(taskId); + if (pendingTimer) { + clearTimeout(pendingTimer); + this.pendingCallbackTimers.delete(taskId); + } + const task = await this.taskRepository.findById(taskId); if (ok && result) { diff --git a/packages/shared/database/src/migrations/002-create-tenant-schema-template.sql b/packages/shared/database/src/migrations/002-create-tenant-schema-template.sql index 6d76529..c645e8c 100644 --- a/packages/shared/database/src/migrations/002-create-tenant-schema-template.sql +++ b/packages/shared/database/src/migrations/002-create-tenant-schema-template.sql @@ -7,15 +7,17 @@ SET LOCAL search_path TO it0_t_{TENANT_ID}; -- Agent Sessions CREATE TABLE agent_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id VARCHAR(20) NOT NULL, - engine_type VARCHAR(20) NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'active', - system_prompt TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - metadata JSONB DEFAULT '{}' + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id VARCHAR(20) NOT NULL, + engine_type VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + system_prompt TEXT, + agent_instance_id UUID NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB DEFAULT '{}' ); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_instance ON agent_sessions(agent_instance_id) WHERE agent_instance_id IS NOT NULL; -- Command Records CREATE TABLE command_records (