Files
MaliangAINovalWriter/AINoval/lib/screens/admin/llm_observability_screen.dart
2025-09-10 00:07:52 +08:00

2332 lines
79 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// LLM可观测性管理页面
/// 用于查看和分析大模型调用日志,便于运维和观察
import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:ainoval/models/admin/llm_observability_models.dart';
import 'package:ainoval/services/api_service/repositories/impl/admin/llm_observability_repository_impl.dart';
import 'package:ainoval/widgets/common/loading_indicator.dart';
import 'package:ainoval/widgets/common/error_view.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:get_it/get_it.dart';
import 'package:flutter/services.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
class LLMObservabilityScreen extends StatefulWidget {
const LLMObservabilityScreen({super.key});
@override
State<LLMObservabilityScreen> createState() => _LLMObservabilityScreenState();
}
class _LLMObservabilityScreenState extends State<LLMObservabilityScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late LLMObservabilityRepositoryImpl _repository;
final String _tag = 'LLMObservabilityScreen';
// 数据状态
List<LLMTrace> _traces = [];
String? _nextCursor;
bool _hasMore = true;
bool _isLoadingMore = false;
Map<String, dynamic> _overviewStats = {};
List<ProviderStatistics> _providerStats = [];
List<ModelStatistics> _modelStats = [];
List<UserStatistics> _userStats = [];
SystemHealthStatus? _systemHealth;
LLMTrace? _selectedTrace;
// UI状态
bool _isLoading = false;
String? _error;
static const int _pageSize = 50;
final ScrollController _listScrollController = ScrollController();
// 搜索条件
LLMTraceSearchCriteria _searchCriteria = const LLMTraceSearchCriteria();
final TextEditingController _userIdController = TextEditingController();
final TextEditingController _providerController = TextEditingController();
final TextEditingController _modelController = TextEditingController();
final TextEditingController _sessionIdController = TextEditingController();
final TextEditingController _contentSearchController = TextEditingController();
final TextEditingController _correlationIdController = TextEditingController();
final TextEditingController _traceIdController = TextEditingController();
String? _callType; // CHAT/STREAMING_CHAT/COMPLETION/STREAMING_COMPLETION
final TextEditingController _tagController = TextEditingController();
DateTime? _startTime;
DateTime? _endTime;
bool? _hasError;
String? _featureType;
@override
void initState() {
super.initState();
_tabController = TabController(length: 5, vsync: this);
_repository = GetIt.instance<LLMObservabilityRepositoryImpl>();
_listScrollController.addListener(() {
if (_listScrollController.position.pixels >=
_listScrollController.position.maxScrollExtent - 200 &&
!_isLoadingMore &&
_hasMore &&
_tabController.index == 1) {
_loadMoreTracesCursor();
}
});
_initializeData();
}
@override
void dispose() {
_tabController.dispose();
_listScrollController.dispose();
_userIdController.dispose();
_providerController.dispose();
_modelController.dispose();
_sessionIdController.dispose();
_contentSearchController.dispose();
_correlationIdController.dispose();
_traceIdController.dispose();
_tagController.dispose();
super.dispose();
}
Future<void> _initializeData() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
await Future.wait([
_resetCursorAndLoad(),
_loadOverviewStatistics(),
_loadProviderStatistics(),
_loadModelStatistics(),
_loadUserStatistics(),
_loadSystemHealth(),
]);
} catch (e) {
setState(() {
_error = e.toString();
});
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _resetCursorAndLoad() async {
setState(() {
_traces = [];
_selectedTrace = null;
_nextCursor = null;
_hasMore = true;
});
await _loadMoreTracesCursor();
}
Future<void> _loadMoreTracesCursor() async {
if (_isLoadingMore || !_hasMore) return;
setState(() {
_isLoadingMore = true;
});
try {
final resp = await _repository.getTracesByCursor(
cursor: _nextCursor,
limit: _pageSize,
userId: _userIdController.text.isEmpty ? null : _userIdController.text,
provider: _providerController.text.isEmpty ? null : _providerController.text,
model: _modelController.text.isEmpty ? null : _modelController.text,
sessionId: _sessionIdController.text.isEmpty ? null : _sessionIdController.text,
hasError: _hasError,
businessType: _featureType,
correlationId: _correlationIdController.text.isEmpty ? null : _correlationIdController.text,
traceId: _traceIdController.text.isEmpty ? null : _traceIdController.text,
type: _callType,
tag: _tagController.text.isEmpty ? null : _tagController.text,
startTime: _startTime,
endTime: _endTime,
);
// 追加并去重
final existingIds = _traces.map((e) => e.id).toSet();
final List<LLMTrace> appended = [
..._traces,
...resp.items.where((e) => !existingIds.contains(e.id)),
];
// 本地内容搜索过滤(可选)
List<LLMTrace> finalList = appended;
if (_contentSearchController.text.isNotEmpty) {
final searchTerm = _contentSearchController.text.toLowerCase();
finalList = appended.where((trace) {
final messages = trace.request.messages;
if (messages != null) {
for (final m in messages) {
final c = m.content;
if (c != null && c.toLowerCase().contains(searchTerm)) return true;
}
}
final rc = trace.response?.content;
if (rc != null && rc.toLowerCase().contains(searchTerm)) return true;
return false;
}).toList();
}
// 维护选中项
LLMTrace? nextSelected = _selectedTrace;
nextSelected ??= finalList.isNotEmpty ? finalList.first : null;
setState(() {
_traces = finalList;
_selectedTrace = nextSelected;
_nextCursor = resp.nextCursor;
_hasMore = resp.hasMore;
});
} catch (e) {
TopToast.error(context, '加载调用日志失败: $e');
} finally {
if (mounted) {
setState(() {
_isLoadingMore = false;
});
}
}
}
Future<void> _loadOverviewStatistics() async {
try {
final stats = await _repository.getOverviewStatistics(
startTime: _startTime,
endTime: _endTime,
);
setState(() {
_overviewStats = stats;
});
} catch (e) {
throw Exception('加载统计概览失败: $e');
}
}
Future<void> _loadProviderStatistics() async {
try {
final stats = await _repository.getProviderStatistics(
startTime: _startTime,
endTime: _endTime,
);
setState(() {
_providerStats = stats;
});
} catch (e) {
AppLogger.e(_tag, '加载提供商统计失败', e);
// 不抛出异常,设置空列表避免崩溃
setState(() {
_providerStats = [];
});
}
}
Future<void> _loadModelStatistics() async {
try {
final stats = await _repository.getModelStatistics(
startTime: _startTime,
endTime: _endTime,
);
setState(() {
_modelStats = stats;
});
} catch (e) {
AppLogger.e(_tag, '加载模型统计失败', e);
// 不抛出异常,设置空列表避免崩溃
setState(() {
_modelStats = [];
});
}
}
Future<void> _loadUserStatistics() async {
try {
final stats = await _repository.getUserStatistics(
startTime: _startTime,
endTime: _endTime,
);
setState(() {
_userStats = stats;
});
} catch (e) {
AppLogger.e(_tag, '加载用户统计失败', e);
// 不抛出异常,设置空列表避免崩溃
setState(() {
_userStats = [];
});
}
}
Future<void> _loadSystemHealth() async {
try {
final health = await _repository.getSystemHealth();
setState(() {
_systemHealth = health;
});
} catch (e) {
AppLogger.e(_tag, '加载系统健康状态失败', e);
// 不抛出异常设置null避免崩溃
setState(() {
_systemHealth = null;
});
}
}
void _searchTraces() {
setState(() {
_searchCriteria = LLMTraceSearchCriteria(
userId: _userIdController.text.isEmpty ? null : _userIdController.text,
provider: _providerController.text.isEmpty ? null : _providerController.text,
model: _modelController.text.isEmpty ? null : _modelController.text,
sessionId: _sessionIdController.text.isEmpty ? null : _sessionIdController.text,
hasError: _hasError,
startTime: _startTime,
endTime: _endTime,
page: 0,
size: _pageSize,
);
});
_resetCursorAndLoad();
}
void _clearSearch() {
setState(() {
_userIdController.clear();
_providerController.clear();
_modelController.clear();
_sessionIdController.clear();
_contentSearchController.clear();
_correlationIdController.clear();
_traceIdController.clear();
_callType = null;
_tagController.clear();
_hasError = null;
_featureType = null;
_startTime = null;
_endTime = null;
_searchCriteria = const LLMTraceSearchCriteria();
});
_resetCursorAndLoad();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: const Center(child: LoadingIndicator()),
);
}
if (_error != null) {
return Scaffold(
body: Center(
child: ErrorView(
error: _error!,
onRetry: _initializeData,
),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('LLM可观测性'),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _initializeData,
tooltip: '刷新数据',
),
IconButton(
icon: const Icon(Icons.health_and_safety),
onPressed: _showSystemHealthDialog,
tooltip: '系统健康状态',
),
],
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '概览', icon: Icon(Icons.dashboard)),
Tab(text: '调用日志', icon: Icon(Icons.list)),
Tab(text: '提供商统计', icon: Icon(Icons.cloud)),
Tab(text: '模型统计', icon: Icon(Icons.smart_toy)),
Tab(text: '用户统计', icon: Icon(Icons.people)),
],
),
),
body: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1600),
child: TabBarView(
controller: _tabController,
children: [
_buildOverviewTab(),
_buildTracesTab(),
_buildProviderStatsTab(),
_buildModelStatsTab(),
_buildUserStatsTab(),
],
),
),
),
);
}
Widget _buildOverviewTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTimeRangeSelector(),
const SizedBox(height: 16),
_buildOverviewCards(),
const SizedBox(height: 16),
_buildTrendsSection(),
const SizedBox(height: 16),
_buildQuickActions(),
],
),
);
}
Widget _buildTrendsSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('趋势图(实验)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_buildTrendMetricDropdown(),
_buildTrendIntervalDropdown(),
_buildTrendBusinessTypeDropdown(),
_buildTrendModelField(),
_buildTrendProviderField(),
ElevatedButton.icon(
onPressed: _loadAndRenderTrends,
icon: const Icon(Icons.show_chart),
label: const Text('生成趋势'),
),
],
),
const SizedBox(height: 12),
_buildTrendChartPlaceholder(),
],
),
),
);
}
// 以下为简化的趋势控件与展示占位,后续可替换为真正折线图组件
String _trendMetric = 'successRate';
String _trendInterval = 'hour';
String? _trendBusinessType;
final _trendModelCtrl = TextEditingController();
final _trendProviderCtrl = TextEditingController();
List<Map<String, Object>> _trendSeries = const [];
Widget _buildTrendMetricDropdown() {
return DropdownButton<String>(
value: _trendMetric,
items: const [
DropdownMenuItem(value: 'successRate', child: Text('成功率')),
DropdownMenuItem(value: 'avgLatency', child: Text('平均延迟')),
DropdownMenuItem(value: 'p90Latency', child: Text('TP90')),
DropdownMenuItem(value: 'p95Latency', child: Text('TP95')),
DropdownMenuItem(value: 'tokens', child: Text('Token用量')),
],
onChanged: (v) => setState(() => _trendMetric = v ?? 'successRate'),
);
}
Widget _buildTrendIntervalDropdown() {
return DropdownButton<String>(
value: _trendInterval,
items: const [
DropdownMenuItem(value: 'hour', child: Text('按小时')),
DropdownMenuItem(value: 'day', child: Text('按天')),
],
onChanged: (v) => setState(() => _trendInterval = v ?? 'hour'),
);
}
Widget _buildTrendBusinessTypeDropdown() {
return SizedBox(
width: 220,
child: DropdownButtonFormField<String?>(
value: _trendBusinessType,
decoration: const InputDecoration(labelText: 'AI功能类型'),
items: const [
DropdownMenuItem<String?>(value: null, child: Text('全部')),
DropdownMenuItem(value: 'TEXT_EXPANSION', child: Text('文本扩写')),
DropdownMenuItem(value: 'TEXT_REFACTOR', child: Text('文本润色')),
DropdownMenuItem(value: 'TEXT_SUMMARY', child: Text('文本总结')),
DropdownMenuItem(value: 'AI_CHAT', child: Text('AI对话')),
DropdownMenuItem(value: 'SCENE_TO_SUMMARY', child: Text('场景转摘要')),
DropdownMenuItem(value: 'SUMMARY_TO_SCENE', child: Text('摘要转场景')),
DropdownMenuItem(value: 'NOVEL_GENERATION', child: Text('小说生成')),
DropdownMenuItem(value: 'PROFESSIONAL_FICTION_CONTINUATION', child: Text('专业续写')),
DropdownMenuItem(value: 'SCENE_BEAT_GENERATION', child: Text('场景节拍生成')),
DropdownMenuItem(value: 'SETTING_TREE_GENERATION', child: Text('设定树生成')),
],
onChanged: (v) => setState(() => _trendBusinessType = v),
),
);
}
Widget _buildTrendModelField() {
return SizedBox(
width: 220,
child: TextField(
controller: _trendModelCtrl,
decoration: const InputDecoration(labelText: '模型(可选)'),
),
);
}
Widget _buildTrendProviderField() {
return SizedBox(
width: 220,
child: TextField(
controller: _trendProviderCtrl,
decoration: const InputDecoration(labelText: '提供商(可选)'),
),
);
}
Future<void> _loadAndRenderTrends() async {
try {
final data = await _repository.getTrends(
metric: _trendMetric,
businessType: _trendBusinessType,
model: _trendModelCtrl.text.isEmpty ? null : _trendModelCtrl.text,
provider: _trendProviderCtrl.text.isEmpty ? null : _trendProviderCtrl.text,
interval: _trendInterval,
startTime: _startTime,
endTime: _endTime,
);
final series = (data['series'] as List?)?.cast<Map<String, Object>>() ?? [];
setState(() {
_trendSeries = series;
});
} catch (e) {
TopToast.error(context, '加载趋势失败: $e');
}
}
Widget _buildTrendChartPlaceholder() {
if (_trendSeries.isEmpty) {
return Container(
height: 220,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.05),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
borderRadius: BorderRadius.circular(8),
),
child: const Text('生成后显示趋势数据(可替换为真实折线图组件)'),
);
}
// 简易表格预览(后续换折线图)
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.2)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('趋势数据', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
..._trendSeries.take(50).map((p) => Text('${p['timestamp']}: ${p['value']}')),
if (_trendSeries.length > 50)
Text('... 共 ${_trendSeries.length}'),
],
),
);
}
Widget _buildTracesTab() {
return Column(
children: [
_buildSearchFilters(),
Expanded(
child: Row(
children: [
Flexible(
flex: 2,
child: _buildLeftListPane(),
),
const VerticalDivider(width: 1),
Flexible(
flex: 3,
child: _buildRightDetailPane(),
),
],
),
),
],
);
}
Widget _buildProviderStatsTab() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _providerStats.length,
itemBuilder: (context, index) {
final providerStat = _providerStats[index];
return _buildProviderStatCard(providerStat);
},
);
}
Widget _buildModelStatsTab() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _modelStats.length,
itemBuilder: (context, index) {
final modelStat = _modelStats[index];
return _buildModelStatCard(modelStat);
},
);
}
Widget _buildUserStatsTab() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _userStats.length,
itemBuilder: (context, index) {
final userStat = _userStats[index];
return _buildUserStatCard(userStat);
},
);
}
Widget _buildTimeRangeSelector() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'时间范围',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
readOnly: true,
decoration: InputDecoration(
labelText: '开始时间',
hintText: _startTime?.toString() ?? '选择开始时间',
suffixIcon: const Icon(Icons.calendar_today),
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _startTime ?? DateTime.now().subtract(const Duration(days: 7)),
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() {
_startTime = date;
});
}
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
readOnly: true,
decoration: InputDecoration(
labelText: '结束时间',
hintText: _endTime?.toString() ?? '选择结束时间',
suffixIcon: const Icon(Icons.calendar_today),
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _endTime ?? DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() {
_endTime = date;
});
}
},
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () {
_loadOverviewStatistics();
_loadProviderStatistics();
_loadModelStatistics();
_loadUserStatistics();
},
child: const Text('应用'),
),
],
),
],
),
),
);
}
Widget _buildOverviewCards() {
return Row(
children: [
Expanded(child: _buildStatCard('总调用次数', _overviewStats['totalCalls']?.toString() ?? '0')),
const SizedBox(width: 8),
Expanded(child: _buildStatCard('成功次数', _overviewStats['successfulCalls']?.toString() ?? '0')),
const SizedBox(width: 8),
Expanded(child: _buildStatCard('失败次数', _overviewStats['failedCalls']?.toString() ?? '0')),
const SizedBox(width: 8),
Expanded(child: _buildStatCard('成功率', '${(_overviewStats['successRate'] ?? 0.0).toStringAsFixed(1)}%')),
],
);
}
Widget _buildStatCard(String title, String value) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
),
);
}
Widget _buildQuickActions() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'快速操作',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton.icon(
onPressed: _exportTraces,
icon: const Icon(Icons.download),
label: const Text('导出日志'),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: _showCleanupDialog,
icon: const Icon(Icons.cleaning_services),
label: const Text('清理旧日志'),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: _showSystemHealthDialog,
icon: const Icon(Icons.health_and_safety),
label: const Text('系统健康检查'),
),
],
),
],
),
),
);
}
Widget _buildSearchFilters() {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'搜索过滤',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _contentSearchController,
decoration: const InputDecoration(
labelText: '内容搜索',
hintText: '搜索提示词或回复内容...',
prefixIcon: Icon(Icons.search),
),
onSubmitted: (_) => _searchTraces(),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _userIdController,
decoration: const InputDecoration(
labelText: '用户ID',
hintText: '输入用户ID',
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _providerController,
decoration: const InputDecoration(
labelText: '提供商',
hintText: '输入提供商名称',
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _modelController,
decoration: const InputDecoration(
labelText: '模型',
hintText: '输入模型名称',
),
),
),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<String?>(
value: _featureType,
decoration: const InputDecoration(
labelText: 'AI功能类型',
),
items: const [
DropdownMenuItem<String?>(value: null, child: Text('全部')),
DropdownMenuItem(value: 'TEXT_EXPANSION', child: Text('文本扩写')),
DropdownMenuItem(value: 'TEXT_REFACTOR', child: Text('文本润色')),
DropdownMenuItem(value: 'TEXT_SUMMARY', child: Text('文本总结')),
DropdownMenuItem(value: 'AI_CHAT', child: Text('AI对话')),
DropdownMenuItem(value: 'SCENE_TO_SUMMARY', child: Text('场景转摘要')),
DropdownMenuItem(value: 'SUMMARY_TO_SCENE', child: Text('摘要转场景')),
DropdownMenuItem(value: 'NOVEL_GENERATION', child: Text('小说生成')),
DropdownMenuItem(value: 'PROFESSIONAL_FICTION_CONTINUATION', child: Text('专业续写')),
DropdownMenuItem(value: 'SCENE_BEAT_GENERATION', child: Text('场景节拍生成')),
DropdownMenuItem(value: 'SETTING_TREE_GENERATION', child: Text('设定树生成')),
],
onChanged: (value) {
setState(() {
_featureType = value;
});
},
),
),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<bool?>(
value: _hasError,
decoration: const InputDecoration(
labelText: '错误状态',
),
items: const [
DropdownMenuItem(value: null, child: Text('全部')),
DropdownMenuItem(value: true, child: Text('有错误')),
DropdownMenuItem(value: false, child: Text('无错误')),
],
onChanged: (value) {
setState(() {
_hasError = value;
});
},
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _correlationIdController,
decoration: const InputDecoration(
labelText: '关联ID (correlationId)',
hintText: '输入关联ID',
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _traceIdController,
decoration: const InputDecoration(
labelText: 'Trace ID',
hintText: '输入Trace ID',
),
),
),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<String?>(
value: _callType,
decoration: const InputDecoration(
labelText: '调用类型',
),
items: const [
DropdownMenuItem(value: null, child: Text('全部')),
DropdownMenuItem(value: 'CHAT', child: Text('CHAT')),
DropdownMenuItem(value: 'STREAMING_CHAT', child: Text('STREAMING_CHAT')),
DropdownMenuItem(value: 'COMPLETION', child: Text('COMPLETION')),
DropdownMenuItem(value: 'STREAMING_COMPLETION', child: Text('STREAMING_COMPLETION')),
],
onChanged: (v) => setState(() => _callType = v),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _tagController,
decoration: const InputDecoration(
labelText: '会话标签 (tag)',
hintText: '输入标签,如 prod/beta',
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton.icon(
onPressed: _searchTraces,
icon: const Icon(Icons.search),
label: const Text('搜索'),
),
const SizedBox(width: 16),
TextButton.icon(
onPressed: _clearSearch,
icon: const Icon(Icons.clear),
label: const Text('清空'),
),
],
),
],
),
),
);
}
// 左侧列表面板
Widget _buildLeftListPane() {
return Column(
children: [
// 顶部信息条与会话筛选提示
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Colors.blue.shade50,
child: Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.blue.shade600),
const SizedBox(width: 8),
Expanded(
child: Text(
_contentSearchController.text.isNotEmpty
? '搜索到 ${_traces.length} 条包含 "${_contentSearchController.text}" 的记录'
: '显示 ${_traces.length} 条记录',
style: TextStyle(fontSize: 14, color: Colors.blue.shade700),
overflow: TextOverflow.ellipsis,
),
),
if (_contentSearchController.text.isNotEmpty)
TextButton.icon(
onPressed: () {
_contentSearchController.clear();
_searchTraces();
},
icon: const Icon(Icons.clear, size: 16),
label: const Text('清除搜索'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
if (_sessionIdController.text.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.teal.shade200),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline, size: 14, color: Colors.teal.shade700),
const SizedBox(width: 4),
Text(
'会话: ${_sessionIdController.text.length > 8 ? _sessionIdController.text.substring(0, 8) : _sessionIdController.text}',
style: TextStyle(fontSize: 12, color: Colors.teal.shade700),
),
const SizedBox(width: 6),
GestureDetector(
onTap: () {
setState(() {
_sessionIdController.clear();
});
_searchTraces();
},
child: Icon(Icons.close, size: 14, color: Colors.teal.shade700),
),
],
),
),
],
],
),
),
// 列表
Expanded(
child: _traces.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_contentSearchController.text.isNotEmpty ? Icons.search_off : Icons.inbox_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
_contentSearchController.text.isNotEmpty
? '未找到包含 "${_contentSearchController.text}" 的记录'
: '暂无调用日志数据',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
],
),
)
: ListView.separated(
controller: _listScrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: _traces.length + ((_isLoadingMore || _hasMore) ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, index) {
if (index >= _traces.length) {
// 底部加载/提示
if (_isLoadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: Text('已无更多')),
);
}
return const SizedBox.shrink();
}
final trace = _traces[index];
final selected = _selectedTrace?.id == trace.id;
return _buildTraceListItem(trace, selected: selected, onTap: () {
setState(() {
_selectedTrace = trace;
});
});
},
),
),
],
);
}
// 右侧详情面板
Widget _buildRightDetailPane() {
final trace = _selectedTrace;
if (trace == null) {
return Center(
child: Text(
'请选择左侧一条调用记录',
style: TextStyle(color: Colors.grey.shade600),
),
);
}
return Column(
children: [
// 详情头部操作栏
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.withOpacity(0.2))),
),
child: Row(
children: [
Icon(Icons.list_alt, size: 18, color: Colors.blueGrey.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
'${trace.provider} - ${trace.model}',
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
Text(
formatDateTime(trace.timestamp),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(width: 12),
if (trace.sessionId != null)
OutlinedButton.icon(
onPressed: () {
final sid = trace.sessionId!;
_sessionIdController.text = sid;
_searchTraces();
},
icon: const Icon(Icons.filter_list),
label: const Text('查看此会话'),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _buildTraceDetails(trace),
),
),
],
);
}
// 左侧列表项
Widget _buildTraceListItem(LLMTrace trace, {required bool selected, required VoidCallback onTap}) {
// 用户与助手消息预览
String userMessagePreview = '';
String assistantMessagePreview = '';
final messages = trace.request.messages;
if (messages != null) {
for (final message in messages) {
if (message.role.toLowerCase() == 'user' && userMessagePreview.isEmpty) {
final content = message.content;
if (content != null) {
userMessagePreview = content.length > 60 ? '${content.substring(0, 60)}...' : content;
}
}
}
}
final responseContent = trace.response?.content;
if (responseContent != null && responseContent.isNotEmpty) {
assistantMessagePreview = responseContent.length > 60 ? '${responseContent.substring(0, 60)}...' : responseContent;
}
return InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: selected ? Colors.blue.shade50 : Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selected ? Colors.blue.shade200 : Colors.grey.withOpacity(0.2)),
),
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatusIcon(trace.status),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'${trace.provider} - ${trace.model}',
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
formatDateTime(trace.timestamp),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
const SizedBox(height: 4),
Wrap(
spacing: 12,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (trace.userId != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.account_circle, size: 14, color: Colors.grey.shade600),
const SizedBox(width: 4),
Text(trace.userId!, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
],
),
if (trace.sessionId != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat, size: 14, color: Colors.grey.shade600),
const SizedBox(width: 4),
Text(
trace.sessionId!.length > 8 ? trace.sessionId!.substring(0, 8) : trace.sessionId!,
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timer, size: 14, color: Colors.purple.shade600),
const SizedBox(width: 4),
Text('${trace.performance?.requestLatencyMs ?? 0}ms', style: TextStyle(fontSize: 11, color: Colors.purple.shade700)),
],
),
if (trace.response?.tokenUsage != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.stacked_line_chart, size: 14, color: Colors.green.shade600),
const SizedBox(width: 4),
Text('${trace.response!.tokenUsage!.totalTokens ?? 0}T', style: TextStyle(fontSize: 11, color: Colors.green.shade700)),
],
),
if ((trace.toolCalls?.isNotEmpty ?? false))
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.build, size: 14, color: Colors.blueGrey.shade600),
const SizedBox(width: 4),
Text('${trace.toolCalls!.length}', style: TextStyle(fontSize: 11, color: Colors.blueGrey.shade700)),
],
),
],
),
if (userMessagePreview.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(Icons.person, size: 14, color: Colors.green.shade600),
const SizedBox(width: 4),
Expanded(
child: Text(
userMessagePreview,
style: TextStyle(fontSize: 12, color: Colors.green.shade700, fontStyle: FontStyle.italic),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
if (assistantMessagePreview.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.smart_toy, size: 14, color: Colors.blue.shade600),
const SizedBox(width: 4),
Expanded(
child: Text(
assistantMessagePreview,
style: TextStyle(fontSize: 12, color: Colors.blue.shade700, fontStyle: FontStyle.italic),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
],
),
),
);
}
Widget _buildStatusIcon(LLMTraceStatus status) {
switch (status) {
case LLMTraceStatus.success:
return const Icon(Icons.check_circle, color: Colors.green, size: 20);
case LLMTraceStatus.error:
return const Icon(Icons.error, color: Colors.red, size: 20);
case LLMTraceStatus.pending:
return const Icon(Icons.hourglass_empty, color: Colors.orange, size: 20);
case LLMTraceStatus.timeout:
return const Icon(Icons.timer_off, color: Colors.red, size: 20);
case LLMTraceStatus.cancelled:
return const Icon(Icons.cancel, color: Colors.grey, size: 20);
}
}
Widget _buildTraceDetails(LLMTrace trace) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
// 展开/折叠由 ExpansionTile 控制;这里作为示例,未来可将详情分段折叠加入统一控制
setState(() {});
},
icon: const Icon(Icons.unfold_more),
label: const Text('展开/折叠全部'),
),
],
),
// 基本信息
_buildCopyableDetailRow('Trace ID', trace.traceId),
_buildCopyableDetailRow('会话ID', trace.sessionId ?? 'N/A'),
_buildDetailRow('时间戳', formatDateTime(trace.timestamp)),
_buildDetailRow('流式', trace.isStreaming ? '' : ''),
const SizedBox(height: 16),
const Divider(),
// 输入内容(重点显示)
_buildInputSection(trace),
const SizedBox(height: 16),
const Divider(),
// 输出内容(重点显示)
if (trace.response != null) _buildOutputSection(trace.response!),
const SizedBox(height: 16),
const Divider(),
// 工具调用(结构化展示)
if (trace.toolCalls?.isNotEmpty ?? false) _buildToolCallsSection(trace),
if (trace.toolCalls?.isNotEmpty ?? false) ...[
const SizedBox(height: 16),
const Divider(),
],
// 模型参数
_buildParametersSection(trace),
// 性能指标
const SizedBox(height: 16),
const Divider(),
_buildPerformanceSection(trace),
// 错误信息
if (trace.error != null) ...[
const SizedBox(height: 16),
const Divider(),
_buildErrorSection(trace.error!),
],
],
);
}
Widget _buildInputSection(LLMTrace trace) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'📝 输入内容 (提示词和上下文)',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blue),
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'消息数量: ${trace.request.messages?.length ?? 0}',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
...(trace.request.messages?.asMap().entries.map((entry) {
final index = entry.key;
final message = entry.value;
return _buildMessageCard(index + 1, message);
}) ?? []),
],
),
),
],
);
}
Widget _buildMessageCard(int index, LLMMessage message) {
MaterialColor roleColor;
IconData roleIcon;
switch (message.role.toLowerCase()) {
case 'system':
roleColor = Colors.purple;
roleIcon = Icons.settings;
break;
case 'user':
roleColor = Colors.green;
roleIcon = Icons.person;
break;
case 'assistant':
roleColor = Colors.blue;
roleIcon = Icons.smart_toy;
break;
default:
roleColor = Colors.grey;
roleIcon = Icons.message;
}
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: roleColor.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(roleIcon, size: 16, color: roleColor),
const SizedBox(width: 4),
Text(
'${message.role.toUpperCase()} #$index',
style: TextStyle(
fontWeight: FontWeight.bold,
color: roleColor,
fontSize: 12,
),
),
if (message.name != null) ...[
const SizedBox(width: 8),
Text(
'Name: ${message.name}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
const Spacer(),
IconButton(
icon: const Icon(Icons.copy, size: 16),
onPressed: () => _copyToClipboard(message.content ?? '', '消息内容'),
tooltip: '复制消息内容',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
),
],
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(4),
),
child: _buildHighlightedText(
message.content ?? '(空内容)',
const TextStyle(
fontSize: 13,
fontFamily: 'monospace',
height: 1.4,
),
),
),
],
),
);
}
Widget _buildOutputSection(LLMResponse response) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'🤖 输出内容 (模型响应)',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.green),
),
const Spacer(),
if (response.content?.isNotEmpty ?? false) ...[
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () => _copyToClipboard(response.content ?? '', '模型响应'),
tooltip: '复制响应内容',
color: Colors.green.shade600,
),
const SizedBox(width: 8),
],
if (response.tokenUsage != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${response.tokenUsage!.totalTokens ?? 0} tokens',
style: TextStyle(
fontSize: 12,
color: Colors.green.shade700,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (response.finishReason != null) ...[
Row(
children: [
Icon(Icons.flag, size: 16, color: Colors.green.shade600),
const SizedBox(width: 4),
Text(
'完成原因: ${response.finishReason}',
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.green.shade600,
fontSize: 12,
),
),
],
),
const SizedBox(height: 8),
],
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.green.shade300),
),
child: (response.content?.isEmpty ?? true)
? const Text(
'(空响应)',
style: TextStyle(
fontSize: 14,
fontFamily: 'monospace',
height: 1.5,
fontStyle: FontStyle.italic,
color: Colors.grey,
),
)
: _buildHighlightedText(
response.content ?? '',
const TextStyle(
fontSize: 14,
fontFamily: 'monospace',
height: 1.5,
),
),
),
if (response.tokenUsage != null) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTokenStat('输入', response.tokenUsage!.promptTokens ?? 0, Colors.blue),
_buildTokenStat('输出', response.tokenUsage!.completionTokens ?? 0, Colors.orange),
_buildTokenStat('总计', response.tokenUsage!.totalTokens ?? 0, Colors.green),
],
),
],
],
),
),
],
);
}
Widget _buildTokenStat(String label, int value, MaterialColor color) {
return Column(
children: [
Text(
value.toString(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: TextStyle(
fontSize: 12,
color: color.shade600,
),
),
],
);
}
Widget _buildParametersSection(LLMTrace trace) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'⚙️ 模型参数',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.orange),
),
const SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
if (trace.request.temperature != null)
_buildParameterChip('温度', trace.request.temperature.toString()),
if (trace.request.topP != null)
_buildParameterChip('Top P', trace.request.topP.toString()),
if (trace.request.topK != null)
_buildParameterChip('Top K', trace.request.topK.toString()),
if (trace.request.maxTokens != null)
_buildParameterChip('最大Token', trace.request.maxTokens.toString()),
if (trace.request.seed != null)
_buildParameterChip('随机种子', trace.request.seed.toString()),
if (trace.request.responseFormat != null)
_buildParameterChip('响应格式', trace.request.responseFormat!),
],
),
],
);
}
Widget _buildToolCallsSection(LLMTrace trace) {
final calls = trace.toolCalls ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'🛠️ 工具调用',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey),
),
const SizedBox(height: 8),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final tc = calls[index];
final args = tc.arguments ?? {};
final argsPretty = _prettyPrintJson(args);
final isTextToSettings = tc.name.toLowerCase() == 'text_to_settings';
// 构造概览UI不直接展示原始JSON
Widget summary;
if (isTextToSettings) {
final nodes = (args['nodes'] is List) ? (args['nodes'] as List) : const [];
final List<Widget> items = [];
items.add(Row(
children: [
_buildKVChip('节点数', nodes.length.toString(), Colors.blueGrey),
const SizedBox(width: 8),
if (args['complete'] != null)
_buildKVChip('complete', args['complete'].toString(), Colors.teal),
],
));
final previewCount = nodes.length > 0 ? (nodes.length >= 3 ? 3 : nodes.length) : 0;
for (int i = 0; i < previewCount; i++) {
final n = nodes[i] as Map? ?? const {};
final type = (n['type'] ?? 'UNKNOWN').toString();
final name = (n['name'] ?? (n['tempId'] ?? '节点')).toString();
items.add(Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
Icon(Icons.label, size: 14, color: Colors.blueGrey.shade600),
const SizedBox(width: 4),
Expanded(
child: Text('$name · $type', style: TextStyle(color: Colors.blueGrey.shade700)),
),
],
),
));
}
if (nodes.length > previewCount) {
items.add(Padding(
padding: const EdgeInsets.only(top: 4),
child: Text('… 其余 ${nodes.length - previewCount} 个节点', style: TextStyle(fontSize: 12, color: Colors.blueGrey.shade500)),
));
}
summary = Column(crossAxisAlignment: CrossAxisAlignment.start, children: items);
} else {
// 通用:展示前若干个 key 的值片段
final keys = args.keys.take(4).toList();
summary = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: keys.map((k) {
final v = args[k];
final text = (v is String) ? v : (v is List || v is Map) ? (v is List ? 'List(${v.length})' : 'Object') : v.toString();
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
_buildKVChip(k.toString(), text.length > 36 ? text.substring(0, 36) + '' : text, Colors.blueGrey),
],
),
);
}).toList(),
);
}
return Container(
decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blueGrey.shade100),
),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
title: Row(
children: [
Icon(Icons.extension, size: 16, color: Colors.blueGrey.shade700),
const SizedBox(width: 6),
Text(
tc.name,
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey.shade800),
),
const SizedBox(width: 8),
Expanded(child: summary),
],
),
trailing: IconButton(
icon: const Icon(Icons.copy, size: 16),
tooltip: '复制原始参数',
onPressed: () => _copyToClipboard(argsPretty, '工具参数'),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.blueGrey.shade100),
),
child: SelectableText(
argsPretty,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, height: 1.5),
),
),
],
),
);
},
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemCount: calls.length,
)
],
);
}
Widget _buildKVChip(String k, String v, MaterialColor color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$k: $v',
style: TextStyle(fontSize: 12, color: color.shade700),
),
);
}
String _prettyPrintJson(Map<String, dynamic> map) {
try {
return const JsonEncoder.withIndent(' ').convert(map);
} catch (_) {
return map.toString();
}
}
Widget _buildParameterChip(String label, String value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$label: $value',
style: TextStyle(
fontSize: 12,
color: Colors.orange.shade700,
),
),
);
}
Widget _buildPerformanceSection(LLMTrace trace) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'📊 性能指标',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.purple),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
if (trace.performance != null)
_buildMetricCard('请求延迟', '${trace.performance!.requestLatencyMs ?? 0}ms', Colors.purple),
if (trace.performance?.firstTokenLatencyMs != null)
_buildMetricCard('首Token延迟', '${trace.performance!.firstTokenLatencyMs}ms', Colors.indigo),
if (trace.performance?.totalDurationMs != null)
_buildMetricCard('总耗时', '${trace.performance!.totalDurationMs}ms', Colors.cyan),
],
),
],
);
}
Widget _buildMetricCard(String label, String value, MaterialColor color) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.shade200),
),
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color.shade700,
),
),
Text(
label,
style: TextStyle(
fontSize: 12,
color: color.shade600,
),
),
],
),
);
}
Widget _buildErrorSection(LLMError error) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'❌ 错误信息',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.red),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('错误类型', error.type ?? '未知错误'),
if (error.code != null)
_buildDetailRow('错误代码', error.code!),
const SizedBox(height: 8),
const Text(
'错误消息:',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
SelectableText(
error.message ?? '无错误消息',
style: const TextStyle(
fontSize: 13,
fontFamily: 'monospace',
color: Colors.red,
),
),
if (error.stackTrace != null) ...[
const SizedBox(height: 8),
ExpansionTile(
title: const Text('堆栈跟踪'),
children: [
SelectableText(
error.stackTrace!,
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
),
),
],
),
],
],
),
),
],
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(
child: Text(value),
),
],
),
);
}
Widget _buildCopyableDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(
child: Row(
children: [
Expanded(child: Text(value)),
IconButton(
icon: const Icon(Icons.copy, size: 16),
tooltip: '复制$label',
onPressed: value.isEmpty || value == 'N/A' ? null : () => _copyToClipboard(value, label),
),
],
),
),
],
),
);
}
Widget _buildProviderStatCard(ProviderStatistics providerStat) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
providerStat.provider,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem('总调用', providerStat.statistics.totalCalls.toString()),
_buildStatItem('成功率', '${providerStat.statistics.successRate.toStringAsFixed(1)}%'),
_buildStatItem('平均延迟', '${providerStat.statistics.averageLatency.toStringAsFixed(0)}ms'),
_buildStatItem('总Token', providerStat.statistics.totalTokens.toString()),
],
),
if (providerStat.models.isNotEmpty) ...[
const SizedBox(height: 16),
const Text('模型详情', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...providerStat.models.map((model) => _buildModelItem(model)),
],
],
),
),
);
}
Widget _buildModelStatCard(ModelStatistics modelStat) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${modelStat.modelName} (${modelStat.provider})',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem('总调用', modelStat.statistics.totalCalls.toString()),
_buildStatItem('成功率', '${modelStat.statistics.successRate.toStringAsFixed(1)}%'),
_buildStatItem('平均延迟', '${modelStat.statistics.averageLatency.toStringAsFixed(0)}ms'),
_buildStatItem('总Token', modelStat.statistics.totalTokens.toString()),
],
),
],
),
),
);
}
Widget _buildUserStatCard(UserStatistics userStat) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'用户: ${userStat.username ?? userStat.userId}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem('总调用', userStat.statistics.totalCalls.toString()),
_buildStatItem('成功率', '${userStat.statistics.successRate.toStringAsFixed(1)}%'),
_buildStatItem('平均延迟', '${userStat.statistics.averageLatency.toStringAsFixed(0)}ms'),
],
),
if (userStat.topModels.isNotEmpty) ...[
const SizedBox(height: 8),
Text('常用模型: ${userStat.topModels.join(', ')}'),
],
if (userStat.topProviders.isNotEmpty) ...[
const SizedBox(height: 4),
Text('常用提供商: ${userStat.topProviders.join(', ')}'),
],
],
),
),
);
}
Widget _buildStatItem(String label, String value) {
return Column(
children: [
Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Text(
label,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
);
}
Widget _buildModelItem(ModelStatistics model) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(model.modelName),
),
Text('${model.statistics.totalCalls}'),
const SizedBox(width: 16),
Text('${model.statistics.successRate.toStringAsFixed(1)}%'),
],
),
);
}
void _exportTraces() async {
try {
setState(() {
_isLoading = true;
});
final traces = await _repository.exportTraces(filterCriteria: _searchCriteria.toJson());
TopToast.success(context, '成功导出 ${traces.length} 条日志');
} catch (e) {
TopToast.error(context, '导出失败: $e');
} finally {
setState(() {
_isLoading = false;
});
}
}
void _showCleanupDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('清理旧日志'),
content: const Text('确定要清理30天前的日志吗此操作不可撤销。'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
Navigator.of(context).pop();
await _cleanupOldTraces();
},
child: const Text('确定'),
),
],
),
);
}
Future<void> _cleanupOldTraces() async {
try {
setState(() {
_isLoading = true;
});
final beforeTime = DateTime.now().subtract(const Duration(days: 30));
final result = await _repository.cleanupOldTraces(beforeTime);
final deletedCount = result['deletedCount'] ?? 0;
TopToast.success(context, '成功清理 $deletedCount 条旧日志');
await _resetCursorAndLoad();
} catch (e) {
TopToast.error(context, '清理失败: $e');
} finally {
setState(() {
_isLoading = false;
});
}
}
void _showSystemHealthDialog() {
if (_systemHealth == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('系统健康状态'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildHealthStatus('整体状态', _systemHealth!.status.name),
const Divider(),
const Text('组件状态', style: TextStyle(fontWeight: FontWeight.bold)),
..._buildComponentHealthStatuses(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
),
);
}
List<Widget> _buildComponentHealthStatuses() {
if (_systemHealth == null) return [];
final components = _systemHealth!.components;
if (components.isEmpty) return [];
return components.entries.map((entry) {
final componentHealth = entry.value;
final status = componentHealth.status.name;
return _buildHealthStatus(entry.key, status);
}).toList();
}
Widget _buildHealthStatus(String name, String status) {
Color color;
String text;
switch (status.toLowerCase()) {
case 'healthy':
color = Colors.green;
text = '健康';
break;
case 'degraded':
color = Colors.orange;
text = '降级';
break;
case 'unhealthy':
color = Colors.red;
text = '不健康';
break;
default:
color = Colors.grey;
text = '未知';
break;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(child: Text(name)),
Text(text, style: TextStyle(color: color, fontWeight: FontWeight.bold)),
],
),
);
}
String formatDateTime(DateTime dateTime) {
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
/// 复制内容到剪贴板
void _copyToClipboard(String content, String type) {
Clipboard.setData(ClipboardData(text: content));
TopToast.success(context, '$type已复制到剪贴板');
}
/// 构建高亮搜索文本的Widget
Widget _buildHighlightedText(String text, TextStyle baseStyle) {
final searchTerm = _contentSearchController.text.trim();
if (searchTerm.isEmpty) {
return SelectableText(text, style: baseStyle);
}
final List<TextSpan> spans = [];
final searchLower = searchTerm.toLowerCase();
final textLower = text.toLowerCase();
int start = 0;
int index = textLower.indexOf(searchLower);
while (index != -1) {
// 添加搜索词之前的文本
if (index > start) {
spans.add(TextSpan(
text: text.substring(start, index),
style: baseStyle,
));
}
// 添加高亮的搜索词
spans.add(TextSpan(
text: text.substring(index, index + searchTerm.length),
style: baseStyle.copyWith(
backgroundColor: Colors.yellow.shade300,
fontWeight: FontWeight.bold,
color: Colors.black,
),
));
start = index + searchTerm.length;
index = textLower.indexOf(searchLower, start);
}
// 添加剩余的文本
if (start < text.length) {
spans.add(TextSpan(
text: text.substring(start),
style: baseStyle,
));
}
return SelectableText.rich(
TextSpan(children: spans),
);
}
}