feat(ux): agent list refresh + OAuth keep-alive + deploy token fix
Flutter: - my_agents_page: refresh agent list on every My Agents tab tap (ref.invalidate in ScaffoldWithNav.onDestinationSelected) - chat_page + my_agents_page: activate AudioSession before launching OAuth browser so iOS keeps network connections alive in background; deactivate when app resumes or binding polling completes agent-service deploy: - Write openclaw.json with correct gateway token and auth-profiles.json with API key BEFORE starting the container, so OpenClaw and bridge always agree on the auth token (fixes token_mismatch on new deployments) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5cf72c4780
commit
13f2d68754
|
|
@ -191,6 +191,10 @@ class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
|
|||
if (index != currentIndex) {
|
||||
GoRouter.of(context).go(ScaffoldWithNav.routes[index]);
|
||||
}
|
||||
// Always refresh agent list when My Agents tab is tapped
|
||||
if (index == 1) {
|
||||
ref.invalidate(myInstancesProvider);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:it0_app/l10n/app_localizations.dart';
|
||||
|
|
@ -796,19 +797,82 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
// OAuth prompt card — shown in timeline when agent triggers DingTalk binding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _OAuthPromptCard extends StatelessWidget {
|
||||
// Stateful so it can activate an audio session before opening the browser,
|
||||
// keeping the app's network connections alive on iOS while in the background.
|
||||
class _OAuthPromptCard extends StatefulWidget {
|
||||
final String url;
|
||||
final String instanceName;
|
||||
|
||||
const _OAuthPromptCard({required this.url, required this.instanceName});
|
||||
|
||||
@override
|
||||
State<_OAuthPromptCard> createState() => _OAuthPromptCardState();
|
||||
}
|
||||
|
||||
class _OAuthPromptCardState extends State<_OAuthPromptCard>
|
||||
with WidgetsBindingObserver {
|
||||
bool _keepAliveActive = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_deactivateKeepAlive();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Deactivate when the user returns to the app after OAuth
|
||||
if (state == AppLifecycleState.resumed && _keepAliveActive) {
|
||||
_deactivateKeepAlive();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _activateKeepAlive() async {
|
||||
if (_keepAliveActive) return;
|
||||
_keepAliveActive = true;
|
||||
try {
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback,
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.mixWithOthers,
|
||||
avAudioSessionMode: AVAudioSessionMode.defaultMode,
|
||||
androidAudioAttributes: AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.music,
|
||||
usage: AndroidAudioUsage.media,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck,
|
||||
));
|
||||
await session.setActive(true);
|
||||
} catch (_) {
|
||||
// Non-fatal — proceed even if audio session fails
|
||||
_keepAliveActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deactivateKeepAlive() async {
|
||||
if (!_keepAliveActive) return;
|
||||
_keepAliveActive = false;
|
||||
try {
|
||||
final session = await AudioSession.instance;
|
||||
await session.setActive(false);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'点击下方按钮,在钉钉中为「$instanceName」完成一键授权绑定。',
|
||||
'点击下方按钮,在钉钉中为「${widget.instanceName}」完成一键授权绑定。',
|
||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
|
@ -824,8 +888,10 @@ class _OAuthPromptCard extends StatelessWidget {
|
|||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: () async {
|
||||
final uri = Uri.tryParse(url);
|
||||
final uri = Uri.tryParse(widget.url);
|
||||
if (uri != null) {
|
||||
// Keep app alive in background while browser is open
|
||||
await _activateKeepAlive();
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
|
@ -763,6 +764,21 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
|||
);
|
||||
final oauthUrl = (res.data as Map<String, dynamic>)['oauthUrl'] as String;
|
||||
final uri = Uri.parse(oauthUrl);
|
||||
// Keep app alive in background while browser handles OAuth
|
||||
try {
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback,
|
||||
avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.mixWithOthers,
|
||||
avAudioSessionMode: AVAudioSessionMode.defaultMode,
|
||||
androidAudioAttributes: AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.music,
|
||||
usage: AndroidAudioUsage.media,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck,
|
||||
));
|
||||
await session.setActive(true);
|
||||
} catch (_) {}
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
setState(() { _phase = _BindPhase.waitingOAuth; });
|
||||
_startPolling();
|
||||
|
|
@ -825,6 +841,10 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
|||
if (bound && mounted) {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
// Deactivate audio keep-alive session now that OAuth is complete
|
||||
try {
|
||||
await (await AudioSession.instance).setActive(false);
|
||||
} catch (_) {}
|
||||
setState(() { _phase = _BindPhase.success; });
|
||||
widget.onBound?.call();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,25 @@ export class AgentInstanceDeployService {
|
|||
envParts.push(`-e DINGTALK_CLIENT_SECRET=${dingTalkClientSecret}`);
|
||||
}
|
||||
|
||||
// Write openclaw.json with the gateway token BEFORE starting the container.
|
||||
// OpenClaw reads gateway.auth.token from this file; it must match the
|
||||
// OPENCLAW_GATEWAY_TOKEN env var that the bridge uses to authenticate.
|
||||
const openclawConfig = JSON.stringify({
|
||||
commands: { native: 'auto', nativeSkills: 'auto', restart: true, ownerDisplay: 'raw' },
|
||||
gateway: { auth: { token } },
|
||||
});
|
||||
const sshCreds = { host: creds.host, port: creds.sshPort, username: creds.sshUser, privateKey: creds.sshKey };
|
||||
await this.sshExec(sshCreds, `mkdir -p /data/openclaw/${instance.id} && printf '%s' '${openclawConfig.replace(/'/g, "'\\''")}' > /data/openclaw/${instance.id}/openclaw.json`);
|
||||
|
||||
// Write gateway API key for LLM proxy in auth-profiles.json
|
||||
const authProfiles = JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
'anthropic-api-key': { type: 'api_key', provider: 'anthropic', key: claudeApiKey },
|
||||
},
|
||||
});
|
||||
await this.sshExec(sshCreds, `mkdir -p /data/openclaw/${instance.id}/agents/main/agent && printf '%s' '${authProfiles.replace(/'/g, "'\\''")}' > /data/openclaw/${instance.id}/agents/main/agent/auth-profiles.json`);
|
||||
|
||||
const cmd = [
|
||||
'docker run -d',
|
||||
`--name ${instance.containerName}`,
|
||||
|
|
@ -205,10 +224,7 @@ export class AgentInstanceDeployService {
|
|||
|
||||
this.logger.log(`Deploying ${instance.containerName} on ${creds.host}:${instance.hostPort}`);
|
||||
|
||||
await this.sshExec(
|
||||
{ host: creds.host, port: creds.sshPort, username: creds.sshUser, privateKey: creds.sshKey },
|
||||
cmd,
|
||||
);
|
||||
await this.sshExec(sshCreds, cmd);
|
||||
|
||||
instance.status = 'running';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue