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) {
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue