xworkmate-app/lib/features/settings/codex_integration_card.dart
2026-04-08 16:35:25 +08:00

404 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../i18n/app_language.dart';
import '../../runtime/platform_environment.dart';
import '../../theme/app_palette.dart';
class CodexIntegrationCard extends StatefulWidget {
const CodexIntegrationCard({super.key, required this.controller});
final AppController controller;
@override
State<CodexIntegrationCard> createState() => _CodexIntegrationCardState();
}
class _CodexIntegrationCardState extends State<CodexIntegrationCard> {
bool _isExporting = false;
String? _exportPath;
String? _errorMessage;
late final TextEditingController _pathController;
@override
void initState() {
super.initState();
_pathController = TextEditingController(
text: widget.controller.configuredCodexCliPath,
);
}
@override
void didUpdateWidget(covariant CodexIntegrationCard oldWidget) {
super.didUpdateWidget(oldWidget);
final nextValue = widget.controller.configuredCodexCliPath;
if (_pathController.text != nextValue) {
_pathController.value = TextEditingValue(
text: nextValue,
selection: TextSelection.collapsed(offset: nextValue.length),
);
}
}
@override
void dispose() {
_pathController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
final controller = widget.controller;
final cooperationLabel = switch (controller.codexCooperationState) {
CodexCooperationState.notStarted => appText('未启动', 'Not started'),
CodexCooperationState.bridgeOnly => appText(
'已启动,但未注册到 Gateway',
'Started, not registered to the gateway',
),
CodexCooperationState.registered => appText(
'已启动并已注册到 Gateway',
'Started and registered to the gateway',
),
};
final binaryLabel = controller.hasDetectedCodexCli
? appText('已就绪', 'Ready')
: appText('未检测到', 'Not found');
final bridgeLabel = controller.isCodexBridgeEnabled
? appText('运行中', 'Running')
: appText('未启用', 'Disabled');
return Card(
color: palette.surfaceSecondary,
elevation: 0,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.terminal_rounded, color: palette.accent, size: 20),
const SizedBox(width: 8),
Text(
appText('Codex CLI 集成', 'Codex CLI Integration'),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
),
],
),
const SizedBox(height: 12),
Text(
appText(
'XWorkmate 当前通过外部 Codex CLI 进程提供桥接能力;启用后会在 Gateway 已连接时注册为协同 code-agent bridge。',
'XWorkmate currently exposes bridge capabilities through an external Codex CLI process. When enabled, it registers as a cooperative code-agent bridge if the gateway is connected.',
),
style: TextStyle(fontSize: 13, color: palette.textSecondary),
),
const SizedBox(height: 16),
_StatusRow(
label: appText('运行时模式', 'Runtime mode'),
value: appText('外部 Codex CLI', 'External Codex CLI'),
),
_StatusRow(
label: appText('Binary 状态', 'Binary status'),
value: binaryLabel,
detail:
controller.resolvedCodexCliPath ??
appText('请安装 codex 或填写路径。', 'Install codex or set a path.'),
),
_StatusRow(
label: appText('Bridge 状态', 'Bridge status'),
value: bridgeLabel,
),
_StatusRow(
label: appText('Gateway 协同状态', 'Gateway cooperation'),
value: cooperationLabel,
),
const SizedBox(height: 16),
TextField(
key: const ValueKey('codex-cli-path-field'),
controller: _pathController,
decoration: InputDecoration(
labelText: appText('Codex CLI 路径', 'Codex CLI path'),
hintText: appText(
'/opt/homebrew/bin/codex',
'/opt/homebrew/bin/codex',
),
suffixIcon: IconButton(
key: const ValueKey('codex-cli-path-save-button'),
onPressed: controller.isCodexBridgeBusy
? null
: _savePathOverride,
icon: const Icon(Icons.save_rounded),
),
),
onSubmitted: (_) => _savePathOverride(),
),
if (!controller.hasDetectedCodexCli) ...[
const SizedBox(height: 8),
Text(
appText(
'未检测到 Codex CLI。可先运行 `npm i -g @openai/codex`,或填写可执行文件绝对路径。',
'Codex CLI was not found. Run `npm i -g @openai/codex` or set the absolute binary path.',
),
style: TextStyle(fontSize: 12, color: palette.textSecondary),
),
],
if (controller.codexRuntimeWarning != null) ...[
const SizedBox(height: 12),
_InfoBanner(
color: Colors.orange,
icon: Icons.warning_amber_rounded,
message: controller.codexRuntimeWarning!,
),
],
if (_exportPath != null) ...[
const SizedBox(height: 12),
_InfoBanner(
color: Colors.green,
icon: Icons.check_circle_rounded,
message: appText('已导出到: ', 'Exported to: ') + _exportPath!,
),
],
if ((_errorMessage ?? controller.codexBridgeError) != null) ...[
const SizedBox(height: 12),
_InfoBanner(
color: Colors.red,
icon: Icons.error_rounded,
message: _errorMessage ?? controller.codexBridgeError!,
),
],
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
key: const ValueKey('codex-bridge-toggle-button'),
onPressed: controller.isCodexBridgeBusy
? null
: controller.isCodexBridgeEnabled
? _disableBridge
: _enableBridge,
icon: controller.isCodexBridgeBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
controller.isCodexBridgeEnabled
? Icons.stop_circle_outlined
: Icons.play_circle_outline_rounded,
size: 16,
),
label: Text(
controller.isCodexBridgeEnabled
? appText('停用 Bridge', 'Disable Bridge')
: appText('启用 Bridge', 'Enable Bridge'),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _isExporting ? null : _exportConfig,
icon: _isExporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_rounded, size: 16),
label: Text(appText('导出配置', 'Export Config')),
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _openCodexTerminal,
icon: const Icon(Icons.terminal_rounded, size: 16),
label: Text(appText('打开终端', 'Open Terminal')),
),
),
],
),
),
);
}
Future<void> _savePathOverride() async {
final trimmed = _pathController.text.trim();
await widget.controller.saveSettings(
widget.controller.settings.copyWith(codexCliPath: trimmed),
refreshAfterSave: false,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(appText('Codex CLI 路径已保存', 'Codex CLI path saved')),
),
);
}
Future<void> _enableBridge() async {
setState(() => _errorMessage = null);
try {
await widget.controller.enableCodexBridge();
} catch (error) {
if (!mounted) {
return;
}
setState(() => _errorMessage = error.toString());
}
}
Future<void> _disableBridge() async {
setState(() => _errorMessage = null);
try {
await widget.controller.disableCodexBridge();
} catch (error) {
if (!mounted) {
return;
}
setState(() => _errorMessage = error.toString());
}
}
Future<void> _exportConfig() async {
setState(() {
_isExporting = true;
_errorMessage = null;
});
try {
final codexHome = resolveCodexHomeDirectory();
final configPath = '$codexHome/config.toml';
final gatewayUrl = widget.controller.aiGatewayUrl;
final apiKey = await widget.controller.loadAiGatewayApiKey();
if (gatewayUrl.isEmpty) {
throw Exception(
appText('LLM API Endpoint 未配置', 'LLM API Endpoint not configured'),
);
}
await widget.controller.runtimeCoordinator.configureCodexForGateway(
gatewayUrl: gatewayUrl,
apiKey: apiKey,
);
setState(() {
_exportPath = configPath;
_isExporting = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isExporting = false;
});
}
}
void _openCodexTerminal() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(appText('请在终端中运行: codex', 'Run in terminal: codex')),
),
);
}
}
class _StatusRow extends StatelessWidget {
const _StatusRow({required this.label, required this.value, this.detail});
final String label;
final String value;
final String? detail;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(fontSize: 12, color: palette.textSecondary),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
),
if (detail != null)
Text(
detail!,
style: TextStyle(
fontSize: 12,
color: palette.textSecondary,
),
),
],
),
),
],
),
);
}
}
class _InfoBanner extends StatelessWidget {
const _InfoBanner({
required this.color,
required this.icon,
required this.message,
});
final Color color;
final IconData icon;
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(message)),
],
),
);
}
}