404 lines
13 KiB
Dart
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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|