Files
flutter_opencode_client/lib/view/page/opencode/setup_page.dart
root 0f4fe33003 Add Opencode (opencode.ai) integration via SSH tunnel
Features:
- Opencode AI chat interface
- SSH tunnel for secure API communication
- Auto-install Opencode on remote servers
- API key management with server-side storage
- Session history and management
- Integration with flutter_server_box server list
2026-04-03 00:41:32 +08:00

391 lines
12 KiB
Dart

import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/data/model/opencode/models.dart';
import 'package:server_box/data/provider/opencode/opencode.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/store/opencode_store.dart';
/// Opencode 连接/安装向导页面
class OpencodeSetupPage extends ConsumerStatefulWidget {
final String serverId;
const OpencodeSetupPage({
super.key,
required this.serverId,
});
@override
ConsumerState<OpencodeSetupPage> createState() => _OpencodeSetupPageState();
}
class _OpencodeSetupPageState extends ConsumerState<OpencodeSetupPage> {
final _formKey = GlobalKey<FormState>();
final _pathController = TextEditingController(text: '/usr/local/bin/opencode');
final _configController = TextEditingController(text: '~/.config/opencode');
final _portController = TextEditingController(text: '8080');
final _apiKeyController = TextEditingController();
bool _autoStart = true;
bool _isConnecting = false;
String? _statusMessage;
@override
void initState() {
super.initState();
_loadExistingConfig();
}
@override
void dispose() {
_pathController.dispose();
_configController.dispose();
_portController.dispose();
_apiKeyController.dispose();
super.dispose();
}
void _loadExistingConfig() {
final config = OpencodeConfigStore().getConfig(widget.serverId);
_pathController.text = config.opencodePath;
_configController.text = config.configPath;
_portController.text = config.apiPort;
_autoStart = config.autoStart;
if (config.apiKey != null) {
_apiKeyController.text = config.apiKey!;
}
}
Future<void> _connect() async {
if (_isConnecting) return;
setState(() {
_isConnecting = true;
_statusMessage = 'Connecting to server...';
});
try {
final serverState = ref.read(serverProvider(widget.serverId));
final connection = ref.read(opencodeConnectionProvider.notifier);
final pki = serverState.spi.keyId != null
? Stores.key.get(serverState.spi.keyId!)
: null;
await connection.connect(
serverState.spi,
privateKey: pki?.key,
passphrase: pki?.password,
);
if (mounted) {
setState(() {
_statusMessage = 'Connected! Checking Opencode installation...';
});
final config = OpencodeServerConfig(
serverId: widget.serverId,
opencodePath: _pathController.text,
configPath: _configController.text,
apiPort: _portController.text,
autoStart: _autoStart,
apiKey: _apiKeyController.text.isNotEmpty ? _apiKeyController.text : null,
);
await OpencodeConfigStore().saveConfig(config);
await connection.checkAndInstall(config: config);
}
} catch (e) {
if (mounted) {
setState(() {
_statusMessage = 'Error: $e';
});
context.showSnackBar('Connection failed: $e');
}
} finally {
if (mounted) {
setState(() {
_isConnecting = false;
});
}
}
}
Future<void> _quickInstall() async {
setState(() {
_isConnecting = true;
_statusMessage = 'Starting automatic installation...';
});
try {
final connection = ref.read(opencodeConnectionProvider.notifier);
final config = OpencodeServerConfig(
serverId: widget.serverId,
opencodePath: _pathController.text,
configPath: _configController.text,
apiPort: _portController.text,
autoStart: _autoStart,
apiKey: _apiKeyController.text.isNotEmpty ? _apiKeyController.text : null,
);
await connection.checkAndInstall(config: config);
if (mounted) {
setState(() {
_statusMessage = 'Installation complete!';
});
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
context.pushReplacement('/opencode/chat/${widget.serverId}');
}
}
} catch (e) {
if (mounted) {
setState(() {
_statusMessage = 'Installation failed: $e';
});
}
} finally {
if (mounted) {
setState(() {
_isConnecting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final connectionState = ref.watch(opencodeConnectionProvider);
final serverState = ref.watch(serverProvider(widget.serverId));
if (connectionState.status == OpencodeConnectionStatus.connected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.pushReplacement('/opencode/chat/${widget.serverId}');
});
}
return Scaffold(
appBar: CustomAppBar(
title: Text('Setup Opencode - ${serverState.spi.name}'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoCard(),
const SizedBox(height: 24),
Text(
'Configuration',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _pathController,
decoration: const InputDecoration(
labelText: 'Opencode Binary Path',
hintText: '/usr/local/bin/opencode',
prefixIcon: Icon(Icons.file_present),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter the path';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _configController,
decoration: const InputDecoration(
labelText: 'Config Directory',
hintText: '~/.config/opencode',
prefixIcon: Icon(Icons.folder),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _portController,
decoration: const InputDecoration(
labelText: 'API Port',
hintText: '8080',
prefixIcon: Icon(Icons.settings_ethernet),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter the port';
}
final port = int.tryParse(value);
if (port == null || port < 1 || port > 65535) {
return 'Invalid port number';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _apiKeyController,
decoration: const InputDecoration(
labelText: 'Opencode API Key (Optional)',
hintText: 'opc_...',
prefixIcon: Icon(Icons.key),
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Auto-start API Server'),
subtitle: const Text('Automatically start Opencode server on connection'),
value: _autoStart,
onChanged: (value) {
setState(() {
_autoStart = value;
});
},
),
const SizedBox(height: 24),
if (_statusMessage != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _getStatusColor(connectionState.status).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
if (_isConnecting)
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
Text(
_statusMessage!,
textAlign: TextAlign.center,
style: TextStyle(
color: _getStatusColor(connectionState.status),
),
),
],
),
),
const SizedBox(height: 24),
],
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isConnecting ? null : _quickInstall,
icon: _isConnecting
? const SizedBox.shrink()
: const Icon(Icons.rocket_launch),
label: Text(_isConnecting ? 'Please wait...' : 'Quick Install & Connect'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isConnecting ? null : _connect,
icon: const Icon(Icons.link),
label: const Text('Connect Only'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
),
),
);
}
Widget _buildInfoCard() {
return Card(
elevation: 0,
color: UIs.primaryColor.withOpacity(0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info, color: UIs.primaryColor),
const SizedBox(width: 8),
Text(
'About Opencode',
style: TextStyle(
fontWeight: FontWeight.bold,
color: UIs.primaryColor,
),
),
],
),
const SizedBox(height: 12),
const Text(
'Opencode (opencode.ai) is an AI assistant for developers. '
'This app connects to your server via SSH to provide AI assistance.',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
const Text(
'• If Opencode is not installed, the app can auto-install it\n'
'• Your API key is saved in plaintext on the server\n'
'• All communication is encrypted via SSH tunnel',
style: TextStyle(fontSize: 13, color: Colors.black87),
),
],
),
),
);
}
Color _getStatusColor(OpencodeConnectionStatus status) {
switch (status) {
case OpencodeConnectionStatus.connected:
return Colors.green;
case OpencodeConnectionStatus.error:
return Colors.red;
case OpencodeConnectionStatus.installing:
case OpencodeConnectionStatus.startingServer:
return Colors.orange;
default:
return UIs.primaryColor;
}
}
}