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}) =>
|
Future<String> transcribeAudio({required String audioPath}) =>
|
||||||
_delegate.transcribeAudio(audioPath: 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
|
@override
|
||||||
Future<Map<String, dynamic>> sendVoiceMessage({
|
Future<Map<String, dynamic>> sendVoiceMessage({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
required String audioPath,
|
required String audioPath,
|
||||||
String language = 'zh',
|
String language = 'zh',
|
||||||
}) =>
|
}) async {
|
||||||
_delegate.sendVoiceMessage(
|
final transcript = await _delegate.transcribeAudio(audioPath: audioPath);
|
||||||
sessionId: sessionId,
|
return createTask(
|
||||||
audioPath: audioPath,
|
sessionId: sessionId,
|
||||||
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 '../../../my_agents/presentation/pages/my_agents_page.dart';
|
||||||
import '../../data/datasources/agent_instance_chat_remote_datasource.dart';
|
import '../../data/datasources/agent_instance_chat_remote_datasource.dart';
|
||||||
import '../../../../core/network/dio_client.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.
|
/// 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);
|
final ds = ref.watch(chatRemoteDatasourceProvider);
|
||||||
return ds.listSessions();
|
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),
|
child: ChatPage(agentName: instance.name),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -720,10 +720,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
if (!isStreaming)
|
if (!isStreaming)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 4),
|
padding: const EdgeInsets.only(left: 4),
|
||||||
child: IconButton(
|
child: Tooltip(
|
||||||
icon: const Icon(Icons.add_circle_outline, size: 22),
|
message: widget.agentName != null
|
||||||
tooltip: AppLocalizations.of(context).chatAddImageTooltip,
|
? '附件功能暂不支持智能体对话'
|
||||||
onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
|
: AppLocalizations.of(context).chatAddImageTooltip,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline, size: 22),
|
||||||
|
onPressed: (isAwaitingApproval || widget.agentName != null)
|
||||||
|
? null
|
||||||
|
: _showAttachmentOptions,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ export class AgentController {
|
||||||
private readonly logger = new Logger(AgentController.name);
|
private readonly logger = new Logger(AgentController.name);
|
||||||
/** Tracks running task promises so cancel/inject can await cleanup. */
|
/** Tracks running task promises so cancel/inject can await cleanup. */
|
||||||
private readonly runningTasks = new Map<string, Promise<void>>();
|
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(
|
constructor(
|
||||||
private readonly engineRegistry: EngineRegistry,
|
private readonly engineRegistry: EngineRegistry,
|
||||||
|
|
@ -830,7 +835,9 @@ export class AgentController {
|
||||||
// Validate that this instance belongs to the requesting user
|
// Validate that this instance belongs to the requesting user
|
||||||
const jwtPayload = this.decodeJwt(req.headers?.['authorization'] as string | undefined);
|
const jwtPayload = this.decodeJwt(req.headers?.['authorization'] as string | undefined);
|
||||||
const userId: string | undefined = jwtPayload?.sub;
|
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');
|
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: 'session_info', sessionId: session.id });
|
||||||
this.gateway.emitStreamEvent(session.id, { type: 'task_info', taskId: task.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, {
|
fetch(bridgeUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: body.prompt,
|
prompt: body.prompt,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
|
@ -903,12 +947,25 @@ export class AgentController {
|
||||||
callbackUrl,
|
callbackUrl,
|
||||||
callbackData: { sessionId: session.id, taskId: task.id },
|
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.logger.error(`[Task ${task.id}] Bridge request failed: ${err.message}`);
|
||||||
this.gateway.emitStreamEvent(session.id, {
|
this.gateway.emitStreamEvent(session.id, { type: 'error', message: msg });
|
||||||
type: 'error',
|
|
||||||
message: `无法连接到智能体:${err.message}`,
|
|
||||||
});
|
|
||||||
task.status = TaskStatus.FAILED;
|
task.status = TaskStatus.FAILED;
|
||||||
task.result = err.message;
|
task.result = err.message;
|
||||||
task.completedAt = new Date();
|
task.completedAt = new Date();
|
||||||
|
|
@ -968,6 +1025,13 @@ export class AgentController {
|
||||||
return { received: true };
|
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);
|
const task = await this.taskRepository.findById(taskId);
|
||||||
|
|
||||||
if (ok && result) {
|
if (ok && result) {
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,17 @@ SET LOCAL search_path TO it0_t_{TENANT_ID};
|
||||||
|
|
||||||
-- Agent Sessions
|
-- Agent Sessions
|
||||||
CREATE TABLE agent_sessions (
|
CREATE TABLE agent_sessions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
tenant_id VARCHAR(20) NOT NULL,
|
tenant_id VARCHAR(20) NOT NULL,
|
||||||
engine_type VARCHAR(20) NOT NULL,
|
engine_type VARCHAR(20) NOT NULL,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
system_prompt TEXT,
|
system_prompt TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
agent_instance_id UUID NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
metadata JSONB DEFAULT '{}'
|
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
|
-- Command Records
|
||||||
CREATE TABLE command_records (
|
CREATE TABLE command_records (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue