feat(agent): complete instance-chat robustness fixes (Fix 2-6)
Fix 2 — Callback timeout wiring:
- Store callbackTimer in pendingCallbackTimers Map after creation
- handleOpenClawAppCallback clears the timer immediately on arrival,
preventing spurious "timeout" errors when the bridge replies in time
Fix 3 — Provider scope isolation:
- Override agentStatusProvider and robotStateProvider in child ProviderScope
so the robot avatar/FAB reflects the instance chat state, not iAgent's
Fix 4 — Voice routing to OpenClaw:
- AgentInstanceChatDatasource.sendVoiceMessage() now calls transcribeAudio()
then routes the transcript through instance-specific createTask() endpoint,
ensuring voice messages reach the user's OpenClaw agent, not iAgent
Fix 5 — Attachment UI in instance mode:
- Attachment button shown as disabled (onPressed: null) with explanatory
tooltip ("附件功能暂不支持智能体对话") when agentName != null
- Prevents misleading UX where attachments appear to work but are silently
dropped by the OpenClaw bridge
Fix 6 — DB schema template:
- Add agent_instance_id UUID NULL to agent_sessions table in migration 002
(tenant schema template) so new tenants get the column from creation
- Add covering index idx_agent_sessions_instance for efficient instance queries
All TypeScript and Flutter analyze checks pass clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8865985019
commit
ea3cbf64a5
|
|
@ -100,15 +100,19 @@ class AgentInstanceChatDatasource implements ChatRemoteDatasource {
|
|||
Future<String> 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<Map<String, dynamic>> sendVoiceMessage({
|
||||
required String sessionId,
|
||||
required String audioPath,
|
||||
String language = 'zh',
|
||||
}) =>
|
||||
_delegate.sendVoiceMessage(
|
||||
}) async {
|
||||
final transcript = await _delegate.transcribeAudio(audioPath: audioPath);
|
||||
return createTask(
|
||||
sessionId: sessionId,
|
||||
audioPath: audioPath,
|
||||
language: language,
|
||||
message: transcript,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -720,10 +720,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
if (!isStreaming)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Tooltip(
|
||||
message: widget.agentName != null
|
||||
? '附件功能暂不支持智能体对话'
|
||||
: AppLocalizations.of(context).chatAddImageTooltip,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline, size: 22),
|
||||
tooltip: AppLocalizations.of(context).chatAddImageTooltip,
|
||||
onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
|
||||
onPressed: (isAwaitingApproval || widget.agentName != null)
|
||||
? null
|
||||
: _showAttachmentOptions,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
|
|
|||
|
|
@ -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<string, Promise<void>>();
|
||||
/**
|
||||
* Tracks pending OpenClaw callback timers keyed by taskId.
|
||||
* Cleared when the bridge callback arrives, preventing spurious timeout errors.
|
||||
*/
|
||||
private readonly pendingCallbackTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ CREATE TABLE agent_sessions (
|
|||
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 (
|
||||
|
|
|
|||
Loading…
Reference in New Issue