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:
hailin 2026-03-08 13:26:05 -07:00
parent 5cf72c4780
commit 13f2d68754
4 changed files with 113 additions and 7 deletions

View File

@ -191,6 +191,10 @@ class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
if (index != currentIndex) { if (index != currentIndex) {
GoRouter.of(context).go(ScaffoldWithNav.routes[index]); GoRouter.of(context).go(ScaffoldWithNav.routes[index]);
} }
// Always refresh agent list when My Agents tab is tapped
if (index == 1) {
ref.invalidate(myInstancesProvider);
}
}, },
), ),
); );

View File

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:it0_app/l10n/app_localizations.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 // 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 url;
final String instanceName; final String instanceName;
const _OAuthPromptCard({required this.url, required this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'点击下方按钮,在钉钉中为「$instanceName」完成一键授权绑定。', '点击下方按钮,在钉钉中为「${widget.instanceName}」完成一键授权绑定。',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -824,8 +888,10 @@ class _OAuthPromptCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
onPressed: () async { onPressed: () async {
final uri = Uri.tryParse(url); final uri = Uri.tryParse(widget.url);
if (uri != null) { if (uri != null) {
// Keep app alive in background while browser is open
await _activateKeepAlive();
await launchUrl(uri, mode: LaunchMode.externalApplication); await launchUrl(uri, mode: LaunchMode.externalApplication);
} }
}, },

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 oauthUrl = (res.data as Map<String, dynamic>)['oauthUrl'] as String;
final uri = Uri.parse(oauthUrl); 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); await launchUrl(uri, mode: LaunchMode.externalApplication);
setState(() { _phase = _BindPhase.waitingOAuth; }); setState(() { _phase = _BindPhase.waitingOAuth; });
_startPolling(); _startPolling();
@ -825,6 +841,10 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
if (bound && mounted) { if (bound && mounted) {
_pollTimer?.cancel(); _pollTimer?.cancel();
_countdownTimer?.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; }); setState(() { _phase = _BindPhase.success; });
widget.onBound?.call(); widget.onBound?.call();
} }

View File

@ -193,6 +193,25 @@ export class AgentInstanceDeployService {
envParts.push(`-e DINGTALK_CLIENT_SECRET=${dingTalkClientSecret}`); 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 = [ const cmd = [
'docker run -d', 'docker run -d',
`--name ${instance.containerName}`, `--name ${instance.containerName}`,
@ -205,10 +224,7 @@ export class AgentInstanceDeployService {
this.logger.log(`Deploying ${instance.containerName} on ${creds.host}:${instance.hostPort}`); this.logger.log(`Deploying ${instance.containerName} on ${creds.host}:${instance.hostPort}`);
await this.sshExec( await this.sshExec(sshCreds, cmd);
{ host: creds.host, port: creds.sshPort, username: creds.sshUser, privateKey: creds.sshKey },
cmd,
);
instance.status = 'running'; instance.status = 'running';
} }