diff --git a/it0_app/lib/core/router/app_router.dart b/it0_app/lib/core/router/app_router.dart index ce5e994..8cb08c3 100644 --- a/it0_app/lib/core/router/app_router.dart +++ b/it0_app/lib/core/router/app_router.dart @@ -191,6 +191,10 @@ class _ScaffoldWithNavState extends ConsumerState 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); + } }, ), ); diff --git a/it0_app/lib/features/chat/presentation/pages/chat_page.dart b/it0_app/lib/features/chat/presentation/pages/chat_page.dart index aed12af..0e5aef4 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -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 { // 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 _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 _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); } }, diff --git a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart index 67f428e..5bd51c5 100644 --- a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart +++ b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart @@ -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)['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(); } diff --git a/packages/services/agent-service/src/infrastructure/services/agent-instance-deploy.service.ts b/packages/services/agent-service/src/infrastructure/services/agent-instance-deploy.service.ts index 7dc17bf..705ce3d 100644 --- a/packages/services/agent-service/src/infrastructure/services/agent-instance-deploy.service.ts +++ b/packages/services/agent-service/src/infrastructure/services/agent-instance-deploy.service.ts @@ -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'; }