2106 lines
72 KiB
Markdown
2106 lines
72 KiB
Markdown
# IT0 Flutter 前端开发指导文档
|
||
|
||
> IT Operations Intelligent Agent — Flutter Android 客户端(Riverpod + Clean Architecture)
|
||
|
||
## 1. 项目概述
|
||
|
||
### 1.1 定位
|
||
|
||
IT0 Flutter App 是服务器集群运维智能体的**驾驶舱**,提供:
|
||
- 实时运维仪表盘
|
||
- AI 对话交互(文字 + 语音)
|
||
- 任务发起与审批
|
||
- 多渠道通知接收
|
||
- 服务器状态监控
|
||
|
||
### 1.2 技术栈
|
||
|
||
| 层面 | 技术选型 |
|
||
|------|---------|
|
||
| 框架 | Flutter 3.x (Android) |
|
||
| 状态管理 | Riverpod 2.x (flutter_riverpod + riverpod_annotation) |
|
||
| 架构 | Clean Architecture 三层 |
|
||
| 网络 | Dio (REST) + web_socket_channel (WebSocket) |
|
||
| 本地存储 | Hive / SharedPreferences |
|
||
| 语音 | speech_to_text + flutter_tts |
|
||
| 路由 | go_router |
|
||
| 代码生成 | freezed + json_serializable + riverpod_generator |
|
||
| 推送 | Firebase Cloud Messaging (FCM) |
|
||
| 图表 | fl_chart |
|
||
| 终端模拟 | xterm (flutter_xterm) |
|
||
|
||
### 1.3 设计原则
|
||
|
||
- **Clean Architecture**:Data → Domain → Presentation 严格分层,依赖方向向内
|
||
- **Riverpod**:全局状态管理,无 context 依赖,可测试
|
||
- **离线优先**:关键数据本地缓存,断网时仍可查看历史
|
||
- **实时优先**:WebSocket 长连接,Agent 输出实时流式展示
|
||
- **多租户感知**:登录后获取可访问租户列表,Dio 拦截器自动注入 `X-Tenant-Id`,切换租户刷新全部数据
|
||
|
||
---
|
||
|
||
## 2. 项目结构
|
||
|
||
### 2.1 整体目录
|
||
|
||
```
|
||
it0_app/
|
||
├── lib/
|
||
│ ├── main.dart # App 入口
|
||
│ ├── app.dart # MaterialApp + Router 配置
|
||
│ │
|
||
│ ├── core/ # 核心基础设施(跨 Feature 共享)
|
||
│ │ ├── config/
|
||
│ │ │ ├── app_config.dart # 环境配置
|
||
│ │ │ └── api_endpoints.dart # API 端点常量
|
||
│ │ ├── network/
|
||
│ │ │ ├── dio_client.dart # Dio 配置(拦截器、token 注入)
|
||
│ │ │ ├── websocket_client.dart # WebSocket 管理(自动重连)
|
||
│ │ │ └── api_result.dart # Result<T> 统一响应类型
|
||
│ │ ├── error/
|
||
│ │ │ ├── failures.dart # 失败类型定义
|
||
│ │ │ └── error_handler.dart # 全局异常处理
|
||
│ │ ├── theme/
|
||
│ │ │ ├── app_theme.dart # 主题配置(暗色为主)
|
||
│ │ │ ├── app_colors.dart # 颜色常量
|
||
│ │ │ └── app_typography.dart # 字体样式
|
||
│ │ ├── router/
|
||
│ │ │ └── app_router.dart # go_router 路由配置
|
||
│ │ ├── widgets/ # 通用组件
|
||
│ │ │ ├── status_badge.dart # 状态徽章(running/error/warning)
|
||
│ │ │ ├── risk_level_chip.dart # 风险等级标签
|
||
│ │ │ ├── loading_overlay.dart
|
||
│ │ │ └── empty_state.dart
|
||
│ │ ├── tenant/ # ★ 多租户上下文
|
||
│ │ │ ├── tenant_provider.dart # 当前租户 Riverpod Provider
|
||
│ │ │ └── tenant_interceptor.dart # Dio 拦截器(注入 X-Tenant-Id)
|
||
│ │ └── utils/
|
||
│ │ ├── date_formatter.dart
|
||
│ │ └── logger.dart
|
||
│ │
|
||
│ └── features/ # 功能模块(按 Feature 划分)
|
||
│ ├── auth/ # 认证
|
||
│ ├── dashboard/ # 仪表盘
|
||
│ ├── chat/ # AI 对话(核心交互 + 驻留指令定义)
|
||
│ ├── tasks/ # 运维任务
|
||
│ ├── standing_orders/ # ★ 驻留指令管理与监控
|
||
│ ├── approvals/ # 审批中心
|
||
│ ├── servers/ # 服务器管理
|
||
│ ├── alerts/ # 告警中心
|
||
│ ├── agent_call/ # ★ Agent 来电响应(Pipecat 语音对话 + 通知)
|
||
│ ├── terminal/ # 远程终端
|
||
│ └── settings/ # 设置
|
||
│
|
||
├── test/
|
||
│ ├── unit/
|
||
│ ├── widget/
|
||
│ └── integration/
|
||
│
|
||
├── pubspec.yaml
|
||
└── analysis_options.yaml
|
||
```
|
||
|
||
### 2.2 单个 Feature 模块结构(Clean Architecture 三层)
|
||
|
||
以 `chat` 模块为例:
|
||
|
||
```
|
||
features/chat/
|
||
├── data/ # 数据层
|
||
│ ├── datasources/
|
||
│ │ ├── chat_remote_datasource.dart # REST API 调用
|
||
│ │ └── chat_local_datasource.dart # 本地缓存(Hive)
|
||
│ ├── models/
|
||
│ │ ├── chat_message_model.dart # API 响应模型(fromJson/toJson)
|
||
│ │ ├── stream_event_model.dart # WebSocket 流事件模型
|
||
│ │ └── session_model.dart
|
||
│ └── repositories/
|
||
│ └── chat_repository_impl.dart # 仓储实现
|
||
│
|
||
├── domain/ # 领域层(零依赖)
|
||
│ ├── entities/
|
||
│ │ ├── chat_message.dart # 消息实体(freezed)
|
||
│ │ ├── chat_session.dart # 会话实体
|
||
│ │ └── stream_event.dart # 流事件实体
|
||
│ ├── repositories/
|
||
│ │ └── chat_repository.dart # 仓储接口(抽象类)
|
||
│ └── usecases/
|
||
│ ├── send_message.dart # 发送消息
|
||
│ ├── start_voice_input.dart # 开始语音输入
|
||
│ ├── get_session_history.dart # 获取历史
|
||
│ └── cancel_task.dart # 取消任务
|
||
│
|
||
└── presentation/ # 展示层
|
||
├── providers/
|
||
│ ├── chat_providers.dart # Riverpod providers
|
||
│ └── voice_providers.dart # 语音相关 providers
|
||
├── pages/
|
||
│ └── chat_page.dart # 对话页面
|
||
├── widgets/
|
||
│ ├── message_bubble.dart # 消息气泡
|
||
│ ├── agent_thinking_indicator.dart # AI 思考动画
|
||
│ ├── tool_execution_card.dart # 工具执行卡片(显示 Bash 命令和输出)
|
||
│ ├── approval_action_card.dart # 审批操作卡片
|
||
│ ├── voice_input_button.dart # 语音输入按钮
|
||
│ └── stream_text_widget.dart # 流式文字渲染
|
||
└── controllers/
|
||
└── chat_controller.dart # 页面控制器(如需要)
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 核心基础设施详细设计
|
||
|
||
### 3.1 网络层 — Dio 客户端
|
||
|
||
```dart
|
||
// core/network/dio_client.dart
|
||
|
||
class DioClient {
|
||
late final Dio _dio;
|
||
|
||
DioClient({required AppConfig config, required TokenStorage tokenStorage}) {
|
||
_dio = Dio(BaseOptions(
|
||
baseUrl: config.apiBaseUrl,
|
||
connectTimeout: const Duration(seconds: 10),
|
||
receiveTimeout: const Duration(seconds: 30),
|
||
));
|
||
|
||
_dio.interceptors.addAll([
|
||
// Token 注入
|
||
InterceptorsWrapper(
|
||
onRequest: (options, handler) async {
|
||
final token = await tokenStorage.getAccessToken();
|
||
if (token != null) {
|
||
options.headers['Authorization'] = 'Bearer $token';
|
||
}
|
||
handler.next(options);
|
||
},
|
||
),
|
||
// ★ 多租户:自动注入 X-Tenant-Id
|
||
TenantInterceptor(),
|
||
// 日志(debug 模式)
|
||
if (config.isDebug) LogInterceptor(responseBody: true),
|
||
// 错误统一处理
|
||
ErrorInterceptor(),
|
||
]);
|
||
}
|
||
|
||
Dio get dio => _dio;
|
||
}
|
||
```
|
||
|
||
### 3.2 网络层 — WebSocket 客户端
|
||
|
||
```dart
|
||
// core/network/websocket_client.dart
|
||
|
||
class WebSocketClient {
|
||
WebSocketChannel? _channel;
|
||
final StreamController<Map<String, dynamic>> _eventController =
|
||
StreamController.broadcast();
|
||
Timer? _heartbeatTimer;
|
||
Timer? _reconnectTimer;
|
||
int _reconnectAttempts = 0;
|
||
static const _maxReconnectAttempts = 10;
|
||
|
||
final AppConfig _config;
|
||
final TokenStorage _tokenStorage;
|
||
|
||
WebSocketClient({required AppConfig config, required TokenStorage tokenStorage})
|
||
: _config = config,
|
||
_tokenStorage = tokenStorage;
|
||
|
||
/// 事件流 — 所有 Feature 监听此 Stream
|
||
Stream<Map<String, dynamic>> get eventStream => _eventController.stream;
|
||
|
||
/// 连接
|
||
Future<void> connect() async {
|
||
final token = await _tokenStorage.getAccessToken();
|
||
final uri = Uri.parse('${_config.wsBaseUrl}/ws?token=$token');
|
||
|
||
_channel = WebSocketChannel.connect(uri);
|
||
_reconnectAttempts = 0;
|
||
|
||
_channel!.stream.listen(
|
||
(data) {
|
||
final event = jsonDecode(data as String) as Map<String, dynamic>;
|
||
_eventController.add(event);
|
||
},
|
||
onDone: () => _handleDisconnect(),
|
||
onError: (error) => _handleDisconnect(),
|
||
);
|
||
|
||
_startHeartbeat();
|
||
}
|
||
|
||
/// 发送消息
|
||
void send(Map<String, dynamic> data) {
|
||
_channel?.sink.add(jsonEncode(data));
|
||
}
|
||
|
||
/// 自动重连(指数退避)
|
||
void _handleDisconnect() {
|
||
_heartbeatTimer?.cancel();
|
||
if (_reconnectAttempts < _maxReconnectAttempts) {
|
||
final delay = Duration(seconds: math.pow(2, _reconnectAttempts).toInt());
|
||
_reconnectTimer = Timer(delay, () {
|
||
_reconnectAttempts++;
|
||
connect();
|
||
});
|
||
}
|
||
}
|
||
|
||
void _startHeartbeat() {
|
||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||
send({'type': 'ping'});
|
||
});
|
||
}
|
||
|
||
void dispose() {
|
||
_heartbeatTimer?.cancel();
|
||
_reconnectTimer?.cancel();
|
||
_channel?.sink.close();
|
||
_eventController.close();
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.3 统一结果类型
|
||
|
||
```dart
|
||
// core/network/api_result.dart
|
||
|
||
@freezed
|
||
sealed class ApiResult<T> with _$ApiResult<T> {
|
||
const factory ApiResult.success(T data) = Success<T>;
|
||
const factory ApiResult.failure(Failure failure) = FailureResult<T>;
|
||
}
|
||
|
||
// core/error/failures.dart
|
||
|
||
@freezed
|
||
sealed class Failure with _$Failure {
|
||
const factory Failure.network({required String message}) = NetworkFailure;
|
||
const factory Failure.server({required String message, required int statusCode}) = ServerFailure;
|
||
const factory Failure.auth({required String message}) = AuthFailure;
|
||
const factory Failure.unknown({required String message}) = UnknownFailure;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Feature 模块详细设计
|
||
|
||
### 4.1 Dashboard(仪表盘)
|
||
|
||
#### 页面布局
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
│ IT0 Dashboard [⚙️] │
|
||
├──────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||
│ │ Servers │ │ Active │ │ Alerts │ │
|
||
│ │ 12 │ │ Tasks 3 │ │ 2 │ │
|
||
│ │ ● 10 OK │ │ 1 await │ │ ⚠ 1 🔴1│ │
|
||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||
│ │
|
||
│ ── 服务器状态 ────────────────────── │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ prod-1 CPU 45% MEM 62% DISK 71% │ │
|
||
│ │ prod-2 CPU 23% MEM 48% DISK 92% │ │ ← 磁盘高亮红色
|
||
│ │ prod-3 CPU 67% MEM 75% DISK 55% │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ── 最近操作 ────────────────────── │
|
||
│ │ 14:23 巡检完成 — 全部正常 │
|
||
│ │ 13:15 prod-2 日志清理 — 成功 │
|
||
│ │ 12:01 告警: prod-2 磁盘 > 90% │
|
||
│ │
|
||
│ ── 快捷操作 ────────────────────── │
|
||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||
│ │ 巡检 │ │ 部署 │ │ 日志 │ │ 终端 │ │
|
||
│ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||
│ │
|
||
├──────────────────────────────────────────┤
|
||
│ [Dashboard] [Chat] [Tasks] [Alerts] │
|
||
└──────────────────────────────────────────┘
|
||
```
|
||
|
||
#### Riverpod Providers
|
||
|
||
```dart
|
||
// features/dashboard/presentation/providers/dashboard_providers.dart
|
||
|
||
/// 服务器状态列表(实时更新)
|
||
@riverpod
|
||
Stream<List<ServerStatus>> serverStatusStream(Ref ref) {
|
||
final wsClient = ref.watch(webSocketClientProvider);
|
||
return wsClient.eventStream
|
||
.where((event) => event['type'] == 'server_status_update')
|
||
.map((event) => (event['servers'] as List)
|
||
.map((s) => ServerStatus.fromJson(s))
|
||
.toList());
|
||
}
|
||
|
||
/// 仪表盘摘要(聚合数据)
|
||
@riverpod
|
||
Future<DashboardSummary> dashboardSummary(Ref ref) async {
|
||
final repo = ref.watch(dashboardRepositoryProvider);
|
||
return repo.getSummary();
|
||
}
|
||
|
||
/// 最近操作日志
|
||
@riverpod
|
||
Future<List<RecentOperation>> recentOperations(Ref ref) async {
|
||
final repo = ref.watch(dashboardRepositoryProvider);
|
||
return repo.getRecentOperations(limit: 20);
|
||
}
|
||
|
||
/// 活跃告警
|
||
@riverpod
|
||
Stream<List<Alert>> activeAlerts(Ref ref) {
|
||
final wsClient = ref.watch(webSocketClientProvider);
|
||
return wsClient.eventStream
|
||
.where((event) => event['type'] == 'alert_update')
|
||
.map((event) => (event['alerts'] as List)
|
||
.map((a) => Alert.fromJson(a))
|
||
.toList());
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.2 Chat(AI 对话交互)— 核心 Feature
|
||
|
||
#### 4.2.1 Domain 层
|
||
|
||
```dart
|
||
// features/chat/domain/entities/chat_message.dart
|
||
|
||
@freezed
|
||
class ChatMessage with _$ChatMessage {
|
||
const factory ChatMessage({
|
||
required String id,
|
||
required MessageRole role, // user / assistant / system
|
||
required String content,
|
||
required DateTime timestamp,
|
||
MessageType? type, // text / tool_use / tool_result / approval / thinking
|
||
ToolExecution? toolExecution, // 工具执行详情
|
||
ApprovalRequest? approvalRequest, // 审批请求
|
||
bool? isStreaming, // 是否正在流式输出
|
||
}) = _ChatMessage;
|
||
}
|
||
|
||
@freezed
|
||
class ToolExecution with _$ToolExecution {
|
||
const factory ToolExecution({
|
||
required String toolName, // Bash / Read / Grep / etc.
|
||
required String input, // 命令内容
|
||
String? output, // 执行结果
|
||
required RiskLevel riskLevel,
|
||
required ToolStatus status, // executing / completed / blocked / awaiting_approval
|
||
}) = _ToolExecution;
|
||
}
|
||
|
||
@freezed
|
||
class ApprovalRequest with _$ApprovalRequest {
|
||
const factory ApprovalRequest({
|
||
required String taskId,
|
||
required String command,
|
||
required RiskLevel riskLevel,
|
||
required String targetServer,
|
||
required DateTime expiresAt,
|
||
ApprovalStatus? status, // pending / approved / rejected
|
||
}) = _ApprovalRequest;
|
||
}
|
||
|
||
// features/chat/domain/entities/stream_event.dart
|
||
|
||
@freezed
|
||
sealed class StreamEvent with _$StreamEvent {
|
||
const factory StreamEvent.thinking({required String content}) = ThinkingEvent;
|
||
const factory StreamEvent.text({required String content}) = TextEvent;
|
||
const factory StreamEvent.toolUse({
|
||
required String toolName,
|
||
required Map<String, dynamic> input,
|
||
}) = ToolUseEvent;
|
||
const factory StreamEvent.toolResult({
|
||
required String toolName,
|
||
required String output,
|
||
required bool isError,
|
||
}) = ToolResultEvent;
|
||
const factory StreamEvent.approvalRequired({
|
||
required String taskId,
|
||
required String command,
|
||
required int riskLevel,
|
||
}) = ApprovalRequiredEvent;
|
||
const factory StreamEvent.completed({required String summary}) = CompletedEvent;
|
||
const factory StreamEvent.error({required String message}) = ErrorEvent;
|
||
// ★ 驻留指令相关事件
|
||
const factory StreamEvent.standingOrderDraft({
|
||
required Map<String, dynamic> draft, // Agent 提取的指令草案
|
||
}) = StandingOrderDraftEvent;
|
||
const factory StreamEvent.standingOrderConfirmed({
|
||
required String orderId,
|
||
required String orderName,
|
||
}) = StandingOrderConfirmedEvent;
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// features/chat/domain/repositories/chat_repository.dart
|
||
|
||
abstract class ChatRepository {
|
||
/// 发送消息并获取流式响应
|
||
Stream<StreamEvent> sendMessage({
|
||
required String sessionId,
|
||
required String message,
|
||
List<String>? attachments,
|
||
});
|
||
|
||
/// 语音输入转文字后发送
|
||
Stream<StreamEvent> sendVoiceMessage({
|
||
required String sessionId,
|
||
required String audioPath,
|
||
});
|
||
|
||
/// 获取会话历史
|
||
Future<ApiResult<List<ChatMessage>>> getSessionHistory(String sessionId);
|
||
|
||
/// 审批操作
|
||
Future<ApiResult<void>> approveCommand(String taskId);
|
||
Future<ApiResult<void>> rejectCommand(String taskId, {String? reason});
|
||
|
||
/// 取消正在执行的任务
|
||
Future<ApiResult<void>> cancelTask(String sessionId);
|
||
|
||
/// ★ 驻留指令:确认 Agent 提取的指令草案
|
||
Future<ApiResult<void>> confirmStandingOrder(String sessionId, Map<String, dynamic> draft);
|
||
|
||
/// ★ 驻留指令:获取当前租户的所有驻留指令
|
||
Future<ApiResult<List<StandingOrder>>> getStandingOrders({String? statusFilter});
|
||
}
|
||
```
|
||
|
||
#### 4.2.2 Data 层
|
||
|
||
```dart
|
||
// features/chat/data/repositories/chat_repository_impl.dart
|
||
|
||
class ChatRepositoryImpl implements ChatRepository {
|
||
final ChatRemoteDataSource _remote;
|
||
final ChatLocalDataSource _local;
|
||
final WebSocketClient _wsClient;
|
||
|
||
ChatRepositoryImpl({
|
||
required ChatRemoteDataSource remote,
|
||
required ChatLocalDataSource local,
|
||
required WebSocketClient wsClient,
|
||
}) : _remote = remote,
|
||
_local = local,
|
||
_wsClient = wsClient;
|
||
|
||
@override
|
||
Stream<StreamEvent> sendMessage({
|
||
required String sessionId,
|
||
required String message,
|
||
List<String>? attachments,
|
||
}) async* {
|
||
// 1. 通过 REST 发起任务
|
||
final taskResult = await _remote.createTask(
|
||
sessionId: sessionId,
|
||
message: message,
|
||
attachments: attachments,
|
||
);
|
||
|
||
// 2. 通过 WebSocket 接收流式事件
|
||
yield* _wsClient.eventStream
|
||
.where((event) => event['sessionId'] == sessionId)
|
||
.map((event) => StreamEventModel.fromJson(event).toDomain())
|
||
.takeWhile((event) =>
|
||
event is! CompletedEvent && event is! ErrorEvent)
|
||
.handleError((error) {
|
||
// 错误处理
|
||
});
|
||
}
|
||
|
||
@override
|
||
Future<ApiResult<void>> approveCommand(String taskId) async {
|
||
try {
|
||
await _remote.approveCommand(taskId);
|
||
return const ApiResult.success(null);
|
||
} catch (e) {
|
||
return ApiResult.failure(Failure.unknown(message: e.toString()));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4.2.3 Presentation 层
|
||
|
||
```dart
|
||
// features/chat/presentation/providers/chat_providers.dart
|
||
|
||
/// 当前会话 ID
|
||
@riverpod
|
||
class CurrentSessionId extends _$CurrentSessionId {
|
||
@override
|
||
String? build() => null;
|
||
|
||
void set(String id) => state = id;
|
||
void clear() => state = null;
|
||
}
|
||
|
||
/// 消息列表(历史 + 实时)
|
||
@riverpod
|
||
class ChatMessages extends _$ChatMessages {
|
||
@override
|
||
List<ChatMessage> build() => [];
|
||
|
||
void addMessage(ChatMessage message) {
|
||
state = [...state, message];
|
||
}
|
||
|
||
void updateLastAssistantMessage(String content) {
|
||
if (state.isEmpty) return;
|
||
final last = state.last;
|
||
if (last.role == MessageRole.assistant) {
|
||
state = [
|
||
...state.sublist(0, state.length - 1),
|
||
last.copyWith(content: last.content + content),
|
||
];
|
||
}
|
||
}
|
||
|
||
void addToolExecution(ToolExecution tool) {
|
||
state = [
|
||
...state,
|
||
ChatMessage(
|
||
id: const Uuid().v4(),
|
||
role: MessageRole.assistant,
|
||
content: '',
|
||
timestamp: DateTime.now(),
|
||
type: MessageType.toolUse,
|
||
toolExecution: tool,
|
||
),
|
||
];
|
||
}
|
||
|
||
void updateToolResult(String toolName, String output, bool isError) {
|
||
// 找到最后一个匹配的 tool_use 消息并更新
|
||
final index = state.lastIndexWhere(
|
||
(m) => m.toolExecution?.toolName == toolName &&
|
||
m.toolExecution?.status == ToolStatus.executing,
|
||
);
|
||
if (index >= 0) {
|
||
final msg = state[index];
|
||
state = [
|
||
...state.sublist(0, index),
|
||
msg.copyWith(
|
||
toolExecution: msg.toolExecution!.copyWith(
|
||
output: output,
|
||
status: isError ? ToolStatus.error : ToolStatus.completed,
|
||
),
|
||
),
|
||
...state.sublist(index + 1),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Agent 状态
|
||
@riverpod
|
||
class AgentStatus extends _$AgentStatus {
|
||
@override
|
||
AgentState build() => AgentState.idle;
|
||
|
||
void setThinking() => state = AgentState.thinking;
|
||
void setExecuting(String toolName) => state = AgentState.executing;
|
||
void setWaitingApproval() => state = AgentState.waitingApproval;
|
||
void setIdle() => state = AgentState.idle;
|
||
}
|
||
|
||
enum AgentState { idle, thinking, executing, waitingApproval }
|
||
|
||
/// 发送消息用例
|
||
@riverpod
|
||
class SendMessage extends _$SendMessage {
|
||
@override
|
||
FutureOr<void> build() {}
|
||
|
||
Future<void> send(String message) async {
|
||
final sessionId = ref.read(currentSessionIdProvider);
|
||
if (sessionId == null) return;
|
||
|
||
final repo = ref.read(chatRepositoryProvider);
|
||
final messages = ref.read(chatMessagesProvider.notifier);
|
||
final agentStatus = ref.read(agentStatusProvider.notifier);
|
||
|
||
// 添加用户消息
|
||
messages.addMessage(ChatMessage(
|
||
id: const Uuid().v4(),
|
||
role: MessageRole.user,
|
||
content: message,
|
||
timestamp: DateTime.now(),
|
||
));
|
||
|
||
// 创建空的 assistant 消息(用于流式填充)
|
||
messages.addMessage(ChatMessage(
|
||
id: const Uuid().v4(),
|
||
role: MessageRole.assistant,
|
||
content: '',
|
||
timestamp: DateTime.now(),
|
||
isStreaming: true,
|
||
));
|
||
|
||
agentStatus.setThinking();
|
||
|
||
// 监听流式事件
|
||
await for (final event in repo.sendMessage(
|
||
sessionId: sessionId,
|
||
message: message,
|
||
)) {
|
||
switch (event) {
|
||
case ThinkingEvent(:final content):
|
||
agentStatus.setThinking();
|
||
// 可选:显示思考过程
|
||
break;
|
||
|
||
case TextEvent(:final content):
|
||
messages.updateLastAssistantMessage(content);
|
||
break;
|
||
|
||
case ToolUseEvent(:final toolName, :final input):
|
||
agentStatus.setExecuting(toolName);
|
||
messages.addToolExecution(ToolExecution(
|
||
toolName: toolName,
|
||
input: jsonEncode(input),
|
||
riskLevel: RiskLevel.l0,
|
||
status: ToolStatus.executing,
|
||
));
|
||
break;
|
||
|
||
case ToolResultEvent(:final toolName, :final output, :final isError):
|
||
messages.updateToolResult(toolName, output, isError);
|
||
break;
|
||
|
||
case ApprovalRequiredEvent(:final taskId, :final command, :final riskLevel):
|
||
agentStatus.setWaitingApproval();
|
||
messages.addMessage(ChatMessage(
|
||
id: const Uuid().v4(),
|
||
role: MessageRole.system,
|
||
content: '需要您的审批',
|
||
timestamp: DateTime.now(),
|
||
type: MessageType.approval,
|
||
approvalRequest: ApprovalRequest(
|
||
taskId: taskId,
|
||
command: command,
|
||
riskLevel: RiskLevel.fromInt(riskLevel),
|
||
targetServer: '',
|
||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
||
),
|
||
));
|
||
break;
|
||
|
||
case CompletedEvent():
|
||
case ErrorEvent():
|
||
agentStatus.setIdle();
|
||
break;
|
||
|
||
// ★ 驻留指令:Agent 提取出指令草案,展示确认卡片
|
||
case StandingOrderDraftEvent(:final draft):
|
||
messages.addMessage(ChatMessage(
|
||
id: const Uuid().v4(),
|
||
role: MessageRole.system,
|
||
content: '驻留指令草案',
|
||
timestamp: DateTime.now(),
|
||
type: MessageType.standingOrderDraft,
|
||
metadata: draft,
|
||
));
|
||
break;
|
||
|
||
case StandingOrderConfirmedEvent(:final orderId, :final orderName):
|
||
messages.addMessage(ChatMessage(
|
||
id: const Uuid().v4(),
|
||
role: MessageRole.assistant,
|
||
content: '✅ 驻留指令「$orderName」已创建($orderId)',
|
||
timestamp: DateTime.now(),
|
||
));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4.2.4 对话页面 UI
|
||
|
||
```dart
|
||
// features/chat/presentation/pages/chat_page.dart
|
||
|
||
class ChatPage extends ConsumerWidget {
|
||
const ChatPage({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final messages = ref.watch(chatMessagesProvider);
|
||
final agentStatus = ref.watch(agentStatusProvider);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('IT0 运维助手'),
|
||
actions: [
|
||
// Agent 状态指示器
|
||
AgentStatusIndicator(status: agentStatus),
|
||
// 引擎切换按钮
|
||
IconButton(
|
||
icon: const Icon(Icons.swap_horiz),
|
||
onPressed: () => _showEngineSelector(context, ref),
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
// 消息列表
|
||
Expanded(
|
||
child: ListView.builder(
|
||
reverse: true,
|
||
itemCount: messages.length,
|
||
itemBuilder: (context, index) {
|
||
final message = messages[messages.length - 1 - index];
|
||
return _buildMessageWidget(message, ref);
|
||
},
|
||
),
|
||
),
|
||
// 输入区域
|
||
ChatInputBar(
|
||
onSendText: (text) => ref.read(sendMessageProvider.notifier).send(text),
|
||
onVoiceStart: () => ref.read(voiceInputProvider.notifier).startListening(),
|
||
onVoiceEnd: () => ref.read(voiceInputProvider.notifier).stopListening(),
|
||
isAgentBusy: agentStatus != AgentState.idle,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMessageWidget(ChatMessage message, WidgetRef ref) {
|
||
return switch (message.type) {
|
||
MessageType.toolUse => ToolExecutionCard(
|
||
execution: message.toolExecution!,
|
||
),
|
||
MessageType.approval => ApprovalActionCard(
|
||
request: message.approvalRequest!,
|
||
onApprove: () => ref.read(chatRepositoryProvider)
|
||
.approveCommand(message.approvalRequest!.taskId),
|
||
onReject: () => ref.read(chatRepositoryProvider)
|
||
.rejectCommand(message.approvalRequest!.taskId),
|
||
),
|
||
MessageType.standingOrderDraft => StandingOrderDraftCard(
|
||
draft: message.metadata!,
|
||
onConfirm: () => ref.read(chatRepositoryProvider)
|
||
.confirmStandingOrder(
|
||
ref.read(currentSessionIdProvider)!,
|
||
message.metadata!,
|
||
),
|
||
onModify: (feedback) => ref.read(sendMessageProvider.notifier)
|
||
.send(feedback), // 用户反馈修改意见,继续对话
|
||
),
|
||
_ => MessageBubble(message: message),
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4.2.5 ★ 驻留指令草案确认卡片
|
||
|
||
```dart
|
||
// features/chat/presentation/widgets/standing_order_draft_card.dart
|
||
|
||
/// 当 Agent 从对话中提取出驻留指令草案时,展示此确认卡片
|
||
///
|
||
/// ┌─────────────────────────────────────────┐
|
||
/// │ 📋 驻留指令草案 │
|
||
/// │ │
|
||
/// │ 名称: 每日生产巡检 │
|
||
/// │ ⏰ 触发: 每天 08:00 │
|
||
/// │ 🎯 目标: 所有 prod 服务器 │
|
||
/// │ │
|
||
/// │ 📌 自治操作: │
|
||
/// │ • 检查 CPU/内存/磁盘 │
|
||
/// │ • 磁盘 >85% 自动清理日志 │
|
||
/// │ │
|
||
/// │ 🚨 升级规则: │
|
||
/// │ • 磁盘 >95% → 📞 电话通知 │
|
||
/// │ • 其他异常 → 📱 短信通知 │
|
||
/// │ │
|
||
/// │ ⚠️ 不会执行: 重启服务、修改配置 │
|
||
/// │ │
|
||
/// │ [修改] [确认创建] │
|
||
/// │ (语音/文字反馈) │
|
||
/// └─────────────────────────────────────────┘
|
||
///
|
||
/// 用户可以:
|
||
/// - 点击「确认创建」→ 调用 confirmStandingOrder API
|
||
/// - 点击「修改」→ 弹出输入框,用户语音/文字说明修改意见
|
||
/// - 直接语音说 "把时间改成7点" → Agent 更新草案后重新展示
|
||
class StandingOrderDraftCard extends StatelessWidget {
|
||
final Map<String, dynamic> draft;
|
||
final VoidCallback onConfirm;
|
||
final ValueChanged<String> onModify;
|
||
// ...
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.3 语音交互
|
||
|
||
#### 4.3.1 语音输入(STT)
|
||
|
||
```dart
|
||
// features/chat/presentation/providers/voice_providers.dart
|
||
|
||
@riverpod
|
||
class VoiceInput extends _$VoiceInput {
|
||
SpeechToText? _speech;
|
||
|
||
@override
|
||
VoiceInputState build() => const VoiceInputState.idle();
|
||
|
||
Future<void> startListening() async {
|
||
_speech ??= SpeechToText();
|
||
final available = await _speech!.initialize(
|
||
onError: (error) => state = VoiceInputState.error(error.errorMsg),
|
||
);
|
||
|
||
if (!available) {
|
||
state = const VoiceInputState.error('语音识别不可用');
|
||
return;
|
||
}
|
||
|
||
state = const VoiceInputState.listening(partialText: '');
|
||
|
||
_speech!.listen(
|
||
onResult: (result) {
|
||
if (result.finalResult) {
|
||
state = VoiceInputState.completed(text: result.recognizedWords);
|
||
// 自动发送
|
||
ref.read(sendMessageProvider.notifier).send(result.recognizedWords);
|
||
} else {
|
||
state = VoiceInputState.listening(partialText: result.recognizedWords);
|
||
}
|
||
},
|
||
localeId: 'zh_CN', // 中文识别
|
||
listenMode: ListenMode.dictation,
|
||
);
|
||
}
|
||
|
||
Future<void> stopListening() async {
|
||
await _speech?.stop();
|
||
}
|
||
}
|
||
|
||
@freezed
|
||
sealed class VoiceInputState with _$VoiceInputState {
|
||
const factory VoiceInputState.idle() = VoiceIdle;
|
||
const factory VoiceInputState.listening({required String partialText}) = VoiceListening;
|
||
const factory VoiceInputState.completed({required String text}) = VoiceCompleted;
|
||
const factory VoiceInputState.error(String message) = VoiceError;
|
||
}
|
||
```
|
||
|
||
#### 4.3.2 语音输出(TTS)
|
||
|
||
```dart
|
||
// features/chat/presentation/providers/tts_provider.dart
|
||
|
||
@riverpod
|
||
class TtsPlayer extends _$TtsPlayer {
|
||
FlutterTts? _tts;
|
||
|
||
@override
|
||
bool build() => false; // isPlaying
|
||
|
||
Future<void> speak(String text) async {
|
||
_tts ??= FlutterTts();
|
||
await _tts!.setLanguage('zh-CN');
|
||
await _tts!.setSpeechRate(0.5);
|
||
await _tts!.setVolume(1.0);
|
||
state = true;
|
||
await _tts!.speak(text);
|
||
_tts!.setCompletionHandler(() => state = false);
|
||
}
|
||
|
||
Future<void> stop() async {
|
||
await _tts?.stop();
|
||
state = false;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.4 Tasks(运维任务)
|
||
|
||
```dart
|
||
// features/tasks/domain/entities/ops_task.dart
|
||
|
||
@freezed
|
||
class OpsTask with _$OpsTask {
|
||
const factory OpsTask({
|
||
required String id,
|
||
required TaskType type, // inspection / deployment / rollback / scaling / recovery
|
||
required String title,
|
||
String? description,
|
||
required TaskStatus status, // pending / running / waitingApproval / completed / failed
|
||
required TaskPriority priority,
|
||
required List<String> targetServerIds,
|
||
String? runbookId,
|
||
String? agentSessionId,
|
||
required DateTime createdAt,
|
||
DateTime? completedAt,
|
||
String? resultSummary,
|
||
}) = _OpsTask;
|
||
}
|
||
```
|
||
|
||
#### 预设快捷任务
|
||
|
||
```dart
|
||
// features/tasks/presentation/widgets/quick_task_panel.dart
|
||
|
||
class QuickTaskPanel extends ConsumerWidget {
|
||
// 预设的常用运维任务
|
||
static const quickTasks = [
|
||
QuickTask(
|
||
icon: Icons.health_and_safety,
|
||
label: '全面巡检',
|
||
prompt: '对所有服务器执行全面健康检查,包括 CPU、内存、磁盘、网络、关键服务状态,给出摘要报告',
|
||
type: TaskType.inspection,
|
||
),
|
||
QuickTask(
|
||
icon: Icons.cleaning_services,
|
||
label: '日志清理',
|
||
prompt: '检查所有服务器的日志目录大小,清理 30 天前的旧日志文件,报告释放的空间',
|
||
type: TaskType.inspection,
|
||
),
|
||
QuickTask(
|
||
icon: Icons.security,
|
||
label: '安全检查',
|
||
prompt: '检查各服务器的安全状态:SSH 登录记录、异常进程、端口开放情况、SSL 证书到期时间',
|
||
type: TaskType.inspection,
|
||
),
|
||
QuickTask(
|
||
icon: Icons.backup,
|
||
label: '备份验证',
|
||
prompt: '检查所有数据库备份的状态、最后备份时间、备份文件大小,验证备份完整性',
|
||
type: TaskType.inspection,
|
||
),
|
||
QuickTask(
|
||
icon: Icons.rocket_launch,
|
||
label: '部署应用',
|
||
prompt: '请告诉我要部署什么应用到哪个环境,我会帮你执行部署流程',
|
||
type: TaskType.deployment,
|
||
),
|
||
QuickTask(
|
||
icon: Icons.terminal,
|
||
label: '自由对话',
|
||
prompt: null, // 直接打开对话页面
|
||
type: null,
|
||
),
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
return GridView.builder(
|
||
shrinkWrap: true,
|
||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 3,
|
||
mainAxisSpacing: 12,
|
||
crossAxisSpacing: 12,
|
||
),
|
||
itemCount: quickTasks.length,
|
||
itemBuilder: (context, index) {
|
||
final task = quickTasks[index];
|
||
return QuickTaskCard(
|
||
task: task,
|
||
onTap: () => _executeQuickTask(context, ref, task),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.5 Approvals(审批中心)
|
||
|
||
```dart
|
||
// features/approvals/presentation/pages/approval_page.dart
|
||
|
||
/// 审批中心 — 展示所有待审批的命令
|
||
/// 每个审批卡片包含:
|
||
/// - 命令内容(高亮语法)
|
||
/// - 风险等级标签(L1/L2 颜色区分)
|
||
/// - 目标服务器
|
||
/// - 剩余审批时间(倒计时)
|
||
/// - 批准 / 拒绝按钮
|
||
/// - 可选:拒绝理由输入
|
||
|
||
/// 审批列表 provider
|
||
@riverpod
|
||
Stream<List<ApprovalRequest>> pendingApprovals(Ref ref) {
|
||
final wsClient = ref.watch(webSocketClientProvider);
|
||
|
||
// 初始加载 + WebSocket 实时更新
|
||
return wsClient.eventStream
|
||
.where((event) => event['type'] == 'approval_update')
|
||
.map((event) => (event['approvals'] as List)
|
||
.map((a) => ApprovalRequest.fromJson(a))
|
||
.toList());
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.6 Servers(服务器管理)
|
||
|
||
```dart
|
||
// features/servers/domain/entities/server.dart
|
||
|
||
@freezed
|
||
class Server with _$Server {
|
||
const factory Server({
|
||
required String id,
|
||
required String name,
|
||
required String host,
|
||
required int port,
|
||
required Environment environment, // dev / staging / prod
|
||
required ServerRole role, // web / db / cache / worker / gateway
|
||
String? clusterId,
|
||
required ServerStatus status, // online / offline / warning / error
|
||
ServerMetrics? latestMetrics,
|
||
}) = _Server;
|
||
}
|
||
|
||
@freezed
|
||
class ServerMetrics with _$ServerMetrics {
|
||
const factory ServerMetrics({
|
||
required double cpuPercent,
|
||
required double memoryPercent,
|
||
required double diskPercent,
|
||
required double networkInMbps,
|
||
required double networkOutMbps,
|
||
required int uptimeSeconds,
|
||
required DateTime recordedAt,
|
||
}) = _ServerMetrics;
|
||
}
|
||
```
|
||
|
||
#### 服务器详情页
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
│ ← prod-1 [SSH] [More] │
|
||
├──────────────────────────────────────────┤
|
||
│ Status: ● Online Uptime: 45d 12h │
|
||
│ IP: 10.0.1.11 Role: Web Server │
|
||
│ Env: Production Cluster: main-k8s │
|
||
├──────────────────────────────────────────┤
|
||
│ │
|
||
│ CPU ▓▓▓▓▓▓▓▓░░░░░░░░ 45% │
|
||
│ MEM ▓▓▓▓▓▓▓▓▓▓░░░░░░ 62% │
|
||
│ DISK ▓▓▓▓▓▓▓▓▓▓▓░░░░░ 71% │
|
||
│ NET ↑ 12.3 Mbps ↓ 45.6 Mbps │
|
||
│ │
|
||
│ ── CPU 趋势 (24h) ────────────── │
|
||
│ [========== 折线图 ==========] │
|
||
│ │
|
||
│ ── 最近告警 ────────────────── │
|
||
│ ⚠ 14:23 内存使用率超过 80% │
|
||
│ ● 13:15 已恢复 │
|
||
│ │
|
||
│ ── 快捷操作 ────────────────── │
|
||
│ [查看日志] [重启服务] [健康检查] │
|
||
│ │
|
||
└──────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 4.7 Alerts(告警中心)
|
||
|
||
```dart
|
||
// features/alerts/presentation/providers/alert_providers.dart
|
||
|
||
/// 实时告警流
|
||
@riverpod
|
||
class AlertNotifier extends _$AlertNotifier {
|
||
@override
|
||
List<Alert> build() => [];
|
||
|
||
/// 监听 WebSocket 告警事件
|
||
void startListening() {
|
||
final wsClient = ref.read(webSocketClientProvider);
|
||
wsClient.eventStream
|
||
.where((event) => event['type'] == 'alert_fired' || event['type'] == 'alert_resolved')
|
||
.listen((event) {
|
||
if (event['type'] == 'alert_fired') {
|
||
final alert = Alert.fromJson(event);
|
||
state = [alert, ...state];
|
||
// 触发本地通知
|
||
_showLocalNotification(alert);
|
||
} else {
|
||
// 更新已恢复的告警
|
||
state = state.map((a) =>
|
||
a.id == event['alertId'] ? a.copyWith(status: AlertStatus.resolved) : a
|
||
).toList();
|
||
}
|
||
});
|
||
}
|
||
|
||
void _showLocalNotification(Alert alert) {
|
||
// 使用 flutter_local_notifications 弹出系统通知
|
||
// 严重告警振动 + 声音
|
||
// 紧急告警使用全屏通知
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.8 Terminal(远程终端)
|
||
|
||
```dart
|
||
// features/terminal/presentation/pages/terminal_page.dart
|
||
|
||
/// 远程终端 — 通过 Agent 执行 SSH 命令
|
||
/// 使用 xterm.dart 渲染终端 UI
|
||
/// 用户输入的命令通过 WebSocket 发送到 agent-service
|
||
/// agent-service 通过 Claude Code 的 Bash 工具执行
|
||
/// 输出实时回传渲染
|
||
|
||
class TerminalPage extends ConsumerStatefulWidget {
|
||
final String serverId;
|
||
|
||
const TerminalPage({super.key, required this.serverId});
|
||
|
||
@override
|
||
ConsumerState<TerminalPage> createState() => _TerminalPageState();
|
||
}
|
||
|
||
class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||
late Terminal terminal;
|
||
// xterm 终端实例 + WebSocket 双向绑定
|
||
// 用户输入 → WebSocket → agent-service → SSH → 服务器
|
||
// 服务器输出 → SSH → agent-service → WebSocket → xterm 渲染
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.9 Settings(设置)
|
||
|
||
```dart
|
||
// features/settings/presentation/pages/settings_page.dart
|
||
|
||
/// 设置页面包含:
|
||
///
|
||
/// 1. 引擎配置
|
||
/// - 当前引擎类型(Claude Code CLI / Claude API / Custom)
|
||
/// - 切换引擎(下拉选择 + 确认对话框)
|
||
/// - Claude Code 路径配置
|
||
/// - API Key 配置(仅 Claude API 模式)
|
||
///
|
||
/// 2. 安全设置
|
||
/// - 风险等级策略(Level 1/2 的超时时间)
|
||
/// - 命令黑名单管理
|
||
/// - 自动审批的 Runbook 列表
|
||
///
|
||
/// 3. 通知设置
|
||
/// - 各渠道开关(推送/短信/电话/邮件/Telegram/微信)
|
||
/// - 升级策略配置
|
||
/// - 免打扰时段
|
||
///
|
||
/// 4. 语音设置
|
||
/// - STT 语言选择
|
||
/// - TTS 语速/音色
|
||
/// - 语音唤醒词(可选)
|
||
///
|
||
/// 5. 外观
|
||
/// - 主题(深色/浅色/跟随系统)
|
||
/// - 终端字体大小
|
||
/// - 仪表盘布局自定义
|
||
///
|
||
/// 6. ★ 租户管理
|
||
/// - 当前租户名称 + ID
|
||
/// - 切换租户(弹出 TenantSelectPage 底部弹窗)
|
||
/// - 租户资源用量概览(服务器/用户/Sessions 配额)
|
||
```
|
||
|
||
---
|
||
|
||
### 4.10 ★ Standing Orders(驻留指令监控)
|
||
|
||
```dart
|
||
// features/standing_orders/domain/entities/standing_order.dart
|
||
|
||
@freezed
|
||
class StandingOrder with _$StandingOrder {
|
||
const factory StandingOrder({
|
||
required String id, // SO-20260208-001
|
||
required String name,
|
||
required String description,
|
||
required TriggerType triggerType, // cron / event / threshold / manual
|
||
required Map<String, dynamic> triggerConfig,
|
||
required Map<String, dynamic> targets,
|
||
required String agentPrompt,
|
||
required List<String> skills,
|
||
required int maxRiskLevel,
|
||
required List<EscalationRule> escalationRules,
|
||
required OrderStatus status, // active / paused / archived
|
||
required DateTime createdAt,
|
||
DateTime? lastExecutedAt,
|
||
ExecutionSummary? lastExecution,
|
||
}) = _StandingOrder;
|
||
}
|
||
|
||
@freezed
|
||
class StandingOrderExecution with _$StandingOrderExecution {
|
||
const factory StandingOrderExecution({
|
||
required String id,
|
||
required String orderId,
|
||
required ExecutionStatus status, // running / completed / failed / awaiting_human / aborted
|
||
required DateTime startedAt,
|
||
DateTime? completedAt,
|
||
String? resultSummary,
|
||
List<String>? actionsTaken,
|
||
String? error,
|
||
String? escalationChannel,
|
||
}) = _StandingOrderExecution;
|
||
}
|
||
```
|
||
|
||
```dart
|
||
// features/standing_orders/presentation/pages/standing_orders_page.dart
|
||
|
||
/// 驻留指令列表页
|
||
///
|
||
/// ┌──────────────────────────────────────────┐
|
||
/// │ 驻留指令 [筛选 ▾]│
|
||
/// ├──────────────────────────────────────────┤
|
||
/// │ │
|
||
/// │ ┌──────────────────────────────────┐ │
|
||
/// │ │ 📋 每日生产巡检 │ │
|
||
/// │ │ ⏰ 每天 08:00 · ● 活跃 │ │
|
||
/// │ │ 上次: 今天 08:00 ✓ 正常 │ │
|
||
/// │ │ 下次: 明天 08:00 │ │
|
||
/// │ │ [详情] [⏸ 暂停] │ │
|
||
/// │ └──────────────────────────────────┘ │
|
||
/// │ │
|
||
/// │ ┌──────────────────────────────────┐ │
|
||
/// │ │ 📋 磁盘空间监控 │ │
|
||
/// │ │ ⚡ 告警触发 · ● 活跃 │ │
|
||
/// │ │ 上次: 1小时前 ⚠ 已升级(电话) │ │
|
||
/// │ │ [详情] [⏸ 暂停] │ │
|
||
/// │ └──────────────────────────────────┘ │
|
||
/// │ │
|
||
/// │ ─── 今日执行摘要 ─── │
|
||
/// │ 执行 5 次 · 成功 4 · 升级 1 │
|
||
/// │ │
|
||
/// │ 提示: 通过 AI 对话创建新的驻留指令 │
|
||
/// │ [打开对话 →] │
|
||
/// │ │
|
||
/// ├──────────────────────────────────────────┤
|
||
/// │ [Dashboard] [Chat] [Tasks] [Orders] │
|
||
/// └──────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 4.11 ★ Agent Call(Agent 来电响应)
|
||
|
||
当 Agent 在无人值守执行中遇到紧急情况,通过 `comm-service` 发起语音电话或 IM 消息联系管理员。Flutter App 需要处理这种「**被动接收 Agent 通知**」的场景。
|
||
|
||
#### 4.11.1 来电通知流程(三层递进)
|
||
|
||
```
|
||
Agent 执行驻留指令 → 遇到紧急状况
|
||
↓ report_escalation tool
|
||
ops-service → comm-service → 三层递进升级
|
||
│
|
||
├── ⏱ 第一层:App 推送唤醒 (0s)
|
||
│ ├── FCM 推送 + WebSocket → Flutter App 全屏通知
|
||
│ ├── 用户点击「接听」→ 进入 App 内语音对话
|
||
│ │ └── WebSocket 音频流 ←→ voice-service (Pipecat)
|
||
│ │ Agent 用语音详细汇报 + 听取指示(支持打断)
|
||
│ ├── 用户直接点击「批准/拒绝」→ 快速决策
|
||
│ └── 用户点击「给 Agent 指示」→ 文字/语音输入
|
||
│
|
||
├── ⏱ 第二层:Pipecat 电话语音对话 (2分钟无响应)
|
||
│ ├── Pipecat 通过 Twilio 拨出电话 → 手机铃声响起
|
||
│ ├── 管理员接听 → Agent 直接开口说话(无 IVR 菜单):
|
||
│ │ "您好,我是 IT0 Agent。prod-2 磁盘 97%,
|
||
│ │ 我建议清理 /var/log 下超过7天的日志。您同意吗?"
|
||
│ ├── 管理员语音回复 → Agent 理解并执行
|
||
│ └── 使用与 App 内完全相同的 Pipecat 引擎(Twilio 只是电话线路)
|
||
│
|
||
└── ⏱ 第三层:扩大通知 (5+ 分钟无响应)
|
||
├── IM (Telegram/企业微信) → 结构化消息 + 操作按钮
|
||
├── 短信 → 简要描述 + 回复 Y/N
|
||
├── 通知备用联系人(重复第一层)
|
||
└── 10+ 分钟 → 邮件通知管理组
|
||
```
|
||
|
||
**语音对话引擎说明**:
|
||
IT0 使用 **Pipecat**(GitHub 10.1k stars)自部署实时语音对话引擎:
|
||
- **STT**:faster-whisper(本地 GPU,中英文自动识别)
|
||
- **TTS**:Kokoro-82M(82M 参数,中英双语,Apache 开源)
|
||
- **LLM**:Claude API(Pipecat 原生 Anthropic 集成)
|
||
- **VAD**:Silero(语音活动检测,支持打断 barge-in)
|
||
- **延迟**:< 500ms 端到端(本地 GPU 推理)
|
||
- **电话**:2 分钟 App 无响应 → Pipecat 通过 Twilio 拨号 → Agent 接通即说话(无 IVR)
|
||
|
||
#### 4.11.2 App 内 Agent 通知卡片
|
||
|
||
```dart
|
||
// features/agent_call/presentation/widgets/agent_escalation_card.dart
|
||
|
||
/// 当 Agent 通过 WebSocket/FCM 推送紧急升级通知时,
|
||
/// App 展示全屏覆盖式通知(类似来电界面)
|
||
///
|
||
/// ┌──────────────────────────────────────────┐
|
||
/// │ │
|
||
/// │ 🤖 IT0 Agent │
|
||
/// │ 紧 急 通 知 │
|
||
/// │ │
|
||
/// │ ───────────────────────────────── │
|
||
/// │ │
|
||
/// │ 驻留指令: 每日生产巡检 │
|
||
/// │ 服务器: prod-2 (10.0.1.22) │
|
||
/// │ │
|
||
/// │ ⚠️ 磁盘使用率已达 97% │
|
||
/// │ Agent 建议: 紧急清理 /var/log 目录 │
|
||
/// │ │
|
||
/// │ 当前等待您的决策... │
|
||
/// │ │
|
||
/// │ ───────────────────────────────── │
|
||
/// │ │
|
||
/// │ ┌──────────┐ ┌──────────────┐ │
|
||
/// │ │ ❌ 拒绝 │ │ ✅ 批准执行 │ │
|
||
/// │ │ (中止) │ │ │ │
|
||
/// │ └──────────┘ └──────────────┘ │
|
||
/// │ │
|
||
/// │ ┌──────────────────────────────┐ │
|
||
/// │ │ 🎙️ 语音对话(详细沟通) │ │
|
||
/// │ │ → 接入 voice-service Pipecat │ │
|
||
/// │ └──────────────────────────────┘ │
|
||
/// │ │
|
||
/// │ ┌──────────────────────────────┐ │
|
||
/// │ │ 💬 文字指示... │ │
|
||
/// │ └──────────────────────────────┘ │
|
||
/// │ │
|
||
/// │ ⏱ 2 分钟内未响应将升级为电话通知 │
|
||
/// │ │
|
||
/// └──────────────────────────────────────────┘
|
||
///
|
||
/// 交互方式:
|
||
/// - 「批准执行」→ 调用 API 回复 approve → Agent 继续执行
|
||
/// - 「拒绝」→ 调用 API 回复 reject → Agent 中止任务
|
||
/// - 「语音对话」→ 跳转 VoiceCallPage,建立 WebSocket 音频流到 voice-service
|
||
/// Agent 用语音详细汇报 + 听取管理员语音指示(支持打断)
|
||
/// - 「文字指示」→ 弹出输入框,输入文字指令
|
||
/// 例如 "不要清理,先查看哪些日志最大" → Agent 按指示执行
|
||
/// - 2分钟无响应 → Pipecat 通过 Twilio 拨出电话,Agent 接通即语音对话
|
||
class AgentEscalationCard extends ConsumerWidget { /* ... */ }
|
||
```
|
||
|
||
#### 4.11.3 可打断式语音对话(Pipecat Voice Call)
|
||
|
||
```dart
|
||
// features/agent_call/presentation/pages/voice_call_page.dart
|
||
|
||
/// App 内实时语音对话页面 — 连接 voice-service (Pipecat)
|
||
///
|
||
/// ┌──────────────────────────────────────────┐
|
||
/// │ │
|
||
/// │ 🤖 IT0 Agent │
|
||
/// │ 实 时 语 音 对 话 │
|
||
/// │ │
|
||
/// │ ◉ 正在通话 02:35 │
|
||
/// │ │
|
||
/// │ Agent: "prod-2 的磁盘使用率已达97%, │
|
||
/// │ 我建议清理 /var/log 下超过7天的 │
|
||
/// │ 日志文件。您同意吗?" │
|
||
/// │ │
|
||
/// │ [===== 语音波形可视化 =====] │
|
||
/// │ │
|
||
/// │ 💡 您可以随时打断说话 │
|
||
/// │ │
|
||
/// │ ┌────┐ ┌──────┐ ┌────┐ │
|
||
/// │ │ 🔇 │ │ ✅ │ │ 📞 │ │
|
||
/// │ │静音 │ │快速批准│ │挂断 │ │
|
||
/// │ └────┘ └──────┘ └────┘ │
|
||
/// │ │
|
||
/// │ ── 对话记录(实时转写)── │
|
||
/// │ Agent: prod-2 磁盘使用率97%... │
|
||
/// │ You: 先看看是哪些文件最大 │
|
||
/// │ Agent: 好的,正在执行 du -sh... │
|
||
/// │ Agent: 最大的文件是 access.log 2.1GB... │
|
||
/// │ You: 清理超过3天的日志吧 │
|
||
/// │ Agent: 好的,正在执行清理... │
|
||
/// │ │
|
||
/// └──────────────────────────────────────────┘
|
||
///
|
||
/// ★ 技术实现(基于 Pipecat voice-service):
|
||
///
|
||
/// ┌─ Flutter App ─────────────────────────────────────┐
|
||
/// │ │
|
||
/// │ 麦克风录音 (PCM 16kHz) │
|
||
/// │ ↓ │
|
||
/// │ WebSocket 发送音频帧 ───→ voice-service:3008 │
|
||
/// │ │ │
|
||
/// │ ├─ Silero VAD │
|
||
/// │ │ (检测说话/沉默) │
|
||
/// │ ├─ faster-whisper │
|
||
/// │ │ (语音→文字) │
|
||
/// │ ├─ Claude API │
|
||
/// │ │ (理解+决策+回复) │
|
||
/// │ └─ Kokoro-82M │
|
||
/// │ (文字→语音) │
|
||
/// │ ↓ │
|
||
/// │ WebSocket 接收音频帧 ←──── 回复音频流 │
|
||
/// │ ↓ │
|
||
/// │ AudioPlayer 播放回复语音 │
|
||
/// │ + │
|
||
/// │ 实时转写文字显示在底部(辅助理解 + 留痕) │
|
||
/// │ │
|
||
/// └────────────────────────────────────────────────────┘
|
||
///
|
||
/// 关键特性:
|
||
/// - 打断(barge-in):用户开始说话 → Pipecat VAD 检测 →
|
||
/// 自动停止 TTS 播放 + 取消 LLM 正在生成的回复
|
||
/// - 延迟 < 500ms:所有 STT/TTS 在 voice-service 本地 GPU 运行
|
||
/// - 实时转写:voice-service 通过 WebSocket 同时发送音频帧 + 文字转写
|
||
/// - 自动检测语言:中文/英文自动切换(faster-whisper 多语言支持)
|
||
/// - 对话上下文:voice-service 从 agent-service 获取驻留指令上下文
|
||
class VoiceCallPage extends ConsumerStatefulWidget { /* ... */ }
|
||
```
|
||
|
||
```dart
|
||
// features/agent_call/data/voice_session_service.dart
|
||
|
||
/// 管理与 voice-service 的 WebSocket 音频流连接
|
||
class VoiceSessionService {
|
||
WebSocketChannel? _channel;
|
||
final AudioRecorder _recorder; // record 包
|
||
final AudioPlayer _player; // just_audio 包
|
||
|
||
/// 建立语音对话连接
|
||
Future<void> connect({
|
||
required String voiceSessionId,
|
||
required String baseUrl, // voice-service URL
|
||
}) async {
|
||
_channel = WebSocketChannel.connect(
|
||
Uri.parse('wss://$baseUrl/ws/voice/$voiceSessionId'),
|
||
);
|
||
|
||
// 接收 voice-service 的消息(音频帧 + 转写文字)
|
||
_channel!.stream.listen((data) {
|
||
if (data is List<int>) {
|
||
// 二进制:音频帧 → 播放
|
||
_player.feedPcmData(Uint8List.fromList(data));
|
||
} else {
|
||
// JSON:转写文字 / 状态更新
|
||
final msg = jsonDecode(data as String);
|
||
switch (msg['type']) {
|
||
case 'transcript': // Agent 或用户的转写文字
|
||
onTranscript?.call(msg['role'], msg['text']);
|
||
case 'agent_action': // Agent 正在执行的操作
|
||
onAgentAction?.call(msg['action']);
|
||
case 'session_end': // 对话结束
|
||
disconnect();
|
||
}
|
||
}
|
||
});
|
||
|
||
// 开始录音,持续发送音频帧到 voice-service
|
||
await _startStreaming();
|
||
}
|
||
|
||
Future<void> _startStreaming() async {
|
||
final stream = await _recorder.startStream(RecordConfig(
|
||
encoder: AudioEncoder.pcm16bits,
|
||
sampleRate: 16000,
|
||
numChannels: 1,
|
||
));
|
||
stream.listen((data) {
|
||
_channel?.sink.add(data); // PCM 音频帧 → voice-service
|
||
});
|
||
}
|
||
|
||
Future<void> disconnect() async {
|
||
await _recorder.stop();
|
||
await _channel?.sink.close();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4.11.4 Agent Call Provider
|
||
|
||
```dart
|
||
// features/agent_call/presentation/providers/agent_call_providers.dart
|
||
|
||
/// 监听 WebSocket 的升级通知事件
|
||
@Riverpod(keepAlive: true)
|
||
class AgentCallListener extends _$AgentCallListener {
|
||
VoiceSessionService? _voiceSession;
|
||
|
||
@override
|
||
AgentCallState build() => const AgentCallState.idle();
|
||
|
||
void initialize() {
|
||
final wsClient = ref.read(webSocketClientProvider);
|
||
wsClient.eventStream
|
||
.where((event) => event['type'] == 'escalation_notification')
|
||
.listen((event) {
|
||
final notification = EscalationNotification.fromJson(event);
|
||
|
||
switch (notification.priority) {
|
||
case 'urgent':
|
||
// 紧急:全屏通知 + 振动 + 声音(类似来电)
|
||
state = AgentCallState.incoming(notification: notification);
|
||
_showFullScreenNotification(notification);
|
||
break;
|
||
case 'high':
|
||
// 高优先级:弹出通知卡片
|
||
state = AgentCallState.incoming(notification: notification);
|
||
_showOverlayNotification(notification);
|
||
break;
|
||
case 'normal':
|
||
// 普通:系统通知栏
|
||
_showSystemNotification(notification);
|
||
break;
|
||
case 'low':
|
||
// 低优先级:静默记录
|
||
break;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 用户快速响应(批准/拒绝)
|
||
Future<void> respond(String action, {String? instruction}) async {
|
||
final notification = (state as AgentCallIncoming).notification;
|
||
await ref.read(chatRepositoryProvider).respondToEscalation(
|
||
executionId: notification.executionId,
|
||
action: action, // 'approve' | 'reject' | 'instruct'
|
||
instruction: instruction,
|
||
);
|
||
state = const AgentCallState.idle();
|
||
}
|
||
|
||
/// ★ 用户选择「语音对话」→ 连接 voice-service (Pipecat)
|
||
Future<void> startVoiceCall() async {
|
||
final notification = (state as AgentCallIncoming).notification;
|
||
_voiceSession = VoiceSessionService();
|
||
|
||
state = AgentCallState.inCall(
|
||
notification: notification,
|
||
duration: Duration.zero,
|
||
);
|
||
|
||
// 连接 Pipecat voice-service WebSocket 音频流
|
||
await _voiceSession!.connect(
|
||
voiceSessionId: notification.voiceSessionId, // 预创建的语音会话 ID
|
||
baseUrl: ref.read(configProvider).voiceServiceUrl,
|
||
);
|
||
|
||
// 启动通话计时
|
||
_startTimer();
|
||
}
|
||
|
||
/// 挂断语音对话
|
||
Future<void> endVoiceCall() async {
|
||
await _voiceSession?.disconnect();
|
||
_voiceSession = null;
|
||
state = const AgentCallState.idle();
|
||
}
|
||
}
|
||
|
||
@freezed
|
||
sealed class AgentCallState with _$AgentCallState {
|
||
const factory AgentCallState.idle() = AgentCallIdle;
|
||
const factory AgentCallState.incoming({
|
||
required EscalationNotification notification,
|
||
}) = AgentCallIncoming;
|
||
const factory AgentCallState.inCall({
|
||
required EscalationNotification notification,
|
||
required Duration duration,
|
||
}) = AgentCallInProgress;
|
||
}
|
||
|
||
/// 升级通知数据
|
||
@freezed
|
||
class EscalationNotification with _$EscalationNotification {
|
||
const factory EscalationNotification({
|
||
required String executionId,
|
||
required String orderId,
|
||
required String orderName,
|
||
required String serverName,
|
||
required String summary,
|
||
required String suggestion,
|
||
required String priority, // urgent/high/normal/low
|
||
required String voiceSessionId, // ★ 预创建的 voice-service 会话 ID
|
||
}) = _EscalationNotification;
|
||
|
||
factory EscalationNotification.fromJson(Map<String, dynamic> json) =>
|
||
_$EscalationNotificationFromJson(json);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 路由配置
|
||
|
||
```dart
|
||
// core/router/app_router.dart
|
||
|
||
final appRouter = GoRouter(
|
||
initialLocation: '/dashboard',
|
||
redirect: (context, state) {
|
||
// 未登录重定向到登录页
|
||
final isLoggedIn = /* check auth state */;
|
||
if (!isLoggedIn && state.matchedLocation != '/login') {
|
||
return '/login';
|
||
}
|
||
// ★ 多租户:已登录但未选择租户 → 跳转租户选择页
|
||
final hasTenant = /* check currentTenantProvider != null */;
|
||
if (isLoggedIn && !hasTenant && state.matchedLocation != '/select-tenant') {
|
||
return '/select-tenant';
|
||
}
|
||
return null;
|
||
},
|
||
routes: [
|
||
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
|
||
// ★ 登录后的租户选择页(多租户时才显示,单租户自动跳过)
|
||
GoRoute(path: '/select-tenant', builder: (_, __) => const TenantSelectPage()),
|
||
|
||
// 底部导航 Shell
|
||
ShellRoute(
|
||
builder: (_, __, child) => MainShell(child: child),
|
||
routes: [
|
||
GoRoute(
|
||
path: '/dashboard',
|
||
builder: (_, __) => const DashboardPage(),
|
||
),
|
||
GoRoute(
|
||
path: '/chat',
|
||
builder: (_, __) => const ChatPage(),
|
||
routes: [
|
||
GoRoute(
|
||
path: ':sessionId',
|
||
builder: (_, state) => ChatPage(
|
||
sessionId: state.pathParameters['sessionId'],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
GoRoute(
|
||
path: '/tasks',
|
||
builder: (_, __) => const TasksPage(),
|
||
routes: [
|
||
GoRoute(
|
||
path: ':taskId',
|
||
builder: (_, state) => TaskDetailPage(
|
||
taskId: state.pathParameters['taskId']!,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
GoRoute(
|
||
path: '/alerts',
|
||
builder: (_, __) => const AlertsPage(),
|
||
),
|
||
GoRoute(
|
||
path: '/servers',
|
||
builder: (_, __) => const ServersPage(),
|
||
routes: [
|
||
GoRoute(
|
||
path: ':serverId',
|
||
builder: (_, state) => ServerDetailPage(
|
||
serverId: state.pathParameters['serverId']!,
|
||
),
|
||
),
|
||
GoRoute(
|
||
path: ':serverId/terminal',
|
||
builder: (_, state) => TerminalPage(
|
||
serverId: state.pathParameters['serverId']!,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
GoRoute(
|
||
path: '/approvals',
|
||
builder: (_, __) => const ApprovalsPage(),
|
||
),
|
||
// ★ 驻留指令
|
||
GoRoute(
|
||
path: '/standing-orders',
|
||
builder: (_, __) => const StandingOrdersPage(),
|
||
routes: [
|
||
GoRoute(
|
||
path: ':orderId',
|
||
builder: (_, state) => StandingOrderDetailPage(
|
||
orderId: state.pathParameters['orderId']!,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
|
||
GoRoute(
|
||
path: '/settings',
|
||
builder: (_, __) => const SettingsPage(),
|
||
),
|
||
// ★ Agent 来电响应(全屏覆盖式,通过 overlay 或 push 导航进入)
|
||
GoRoute(
|
||
path: '/agent-call',
|
||
builder: (_, __) => const AgentEscalationPage(),
|
||
),
|
||
GoRoute(
|
||
path: '/agent-voice-call',
|
||
builder: (_, __) => const VoiceCallPage(),
|
||
),
|
||
],
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 主题设计
|
||
|
||
```dart
|
||
// core/theme/app_theme.dart
|
||
|
||
class AppTheme {
|
||
/// IT0 使用深色主题为主(运维工具审美)
|
||
static ThemeData get dark => ThemeData(
|
||
brightness: Brightness.dark,
|
||
colorScheme: ColorScheme.dark(
|
||
primary: const Color(0xFF6C63FF), // 主色:偏紫的蓝
|
||
secondary: const Color(0xFF03DAC6), // 辅助色:青绿
|
||
surface: const Color(0xFF1E1E2E), // 卡片背景
|
||
error: const Color(0xFFCF6679),
|
||
),
|
||
scaffoldBackgroundColor: const Color(0xFF0D0D1A), // 深色背景
|
||
cardTheme: CardTheme(
|
||
color: const Color(0xFF1E1E2E),
|
||
elevation: 2,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
// 终端风格字体
|
||
textTheme: const TextTheme(
|
||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontSize: 14),
|
||
),
|
||
);
|
||
|
||
/// 状态颜色
|
||
static const statusColors = {
|
||
'online': Color(0xFF4CAF50), // 绿色
|
||
'warning': Color(0xFFFF9800), // 橙色
|
||
'error': Color(0xFFF44336), // 红色
|
||
'offline': Color(0xFF9E9E9E), // 灰色
|
||
};
|
||
|
||
/// 风险等级颜色
|
||
static const riskColors = {
|
||
0: Color(0xFF4CAF50), // L0 绿色 — 安全
|
||
1: Color(0xFFFF9800), // L1 橙色 — 需确认
|
||
2: Color(0xFFF44336), // L2 红色 — 需审批
|
||
3: Color(0xFF9C27B0), // L3 紫色 — 禁止
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Dependency Injection(Riverpod Provider 树)
|
||
|
||
```dart
|
||
// 全局基础设施 Providers
|
||
|
||
@Riverpod(keepAlive: true)
|
||
AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
|
||
|
||
@Riverpod(keepAlive: true)
|
||
DioClient dioClient(Ref ref) {
|
||
final config = ref.watch(appConfigProvider);
|
||
final tokenStorage = ref.watch(tokenStorageProvider);
|
||
return DioClient(config: config, tokenStorage: tokenStorage);
|
||
}
|
||
|
||
@Riverpod(keepAlive: true)
|
||
WebSocketClient webSocketClient(Ref ref) {
|
||
final config = ref.watch(appConfigProvider);
|
||
final tokenStorage = ref.watch(tokenStorageProvider);
|
||
final client = WebSocketClient(config: config, tokenStorage: tokenStorage);
|
||
// App 启动时自动连接
|
||
client.connect();
|
||
ref.onDispose(() => client.dispose());
|
||
return client;
|
||
}
|
||
|
||
@Riverpod(keepAlive: true)
|
||
TokenStorage tokenStorage(Ref ref) => TokenStorage();
|
||
|
||
// ★ 多租户 Providers
|
||
|
||
/// 当前用户可访问的租户列表(登录后获取)
|
||
@Riverpod(keepAlive: true)
|
||
class TenantList extends _$TenantList {
|
||
@override
|
||
Future<List<TenantSummary>> build() async {
|
||
final dio = ref.watch(dioClientProvider);
|
||
final response = await dio.dio.get('/tenants');
|
||
return (response.data as List)
|
||
.map((e) => TenantSummary.fromJson(e))
|
||
.toList();
|
||
}
|
||
}
|
||
|
||
/// 当前选中的租户(持久化到本地存储)
|
||
@Riverpod(keepAlive: true)
|
||
class CurrentTenant extends _$CurrentTenant {
|
||
@override
|
||
String? build() {
|
||
// 从 SharedPreferences 恢复上次选中的租户
|
||
final prefs = ref.watch(sharedPreferencesProvider);
|
||
return prefs.getString('current_tenant_id');
|
||
}
|
||
|
||
void switchTenant(String tenantId) {
|
||
state = tenantId;
|
||
final prefs = ref.read(sharedPreferencesProvider);
|
||
prefs.setString('current_tenant_id', tenantId);
|
||
// 切换后使所有数据 Provider 失效 → 触发重新拉取
|
||
ref.invalidateSelf();
|
||
}
|
||
}
|
||
|
||
/// Dio 拦截器:自动读取 currentTenantProvider 并注入到每个请求
|
||
// core/tenant/tenant_interceptor.dart
|
||
class TenantInterceptor extends Interceptor {
|
||
// 通过 ProviderContainer 全局访问(非 widget 上下文)
|
||
static String? _currentTenantId;
|
||
static void updateTenantId(String? id) => _currentTenantId = id;
|
||
|
||
@override
|
||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||
if (_currentTenantId != null) {
|
||
options.headers['X-Tenant-Id'] = _currentTenantId;
|
||
}
|
||
handler.next(options);
|
||
}
|
||
}
|
||
|
||
// Feature-level Providers(以 chat 为例)
|
||
|
||
@riverpod
|
||
ChatRemoteDataSource chatRemoteDataSource(Ref ref) {
|
||
final dio = ref.watch(dioClientProvider);
|
||
return ChatRemoteDataSource(dio: dio.dio);
|
||
}
|
||
|
||
@riverpod
|
||
ChatLocalDataSource chatLocalDataSource(Ref ref) {
|
||
return ChatLocalDataSource();
|
||
}
|
||
|
||
@riverpod
|
||
ChatRepository chatRepository(Ref ref) {
|
||
return ChatRepositoryImpl(
|
||
remote: ref.watch(chatRemoteDataSourceProvider),
|
||
local: ref.watch(chatLocalDataSourceProvider),
|
||
wsClient: ref.watch(webSocketClientProvider),
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 推送通知集成
|
||
|
||
```dart
|
||
// core/notifications/fcm_service.dart
|
||
|
||
/// Firebase Cloud Messaging 集成
|
||
///
|
||
/// 1. 后端 comm-service 在以下场景发送 FCM 推送:
|
||
/// - 告警触发(severity >= warning)
|
||
/// - 审批请求(需要用户操作)
|
||
/// - 任务完成/失败
|
||
/// - 升级通知(短信/电话后仍需 App 确认)
|
||
///
|
||
/// 2. 前端处理推送:
|
||
/// - 前台:显示应用内 Snackbar / Banner
|
||
/// - 后台:系统通知栏
|
||
/// - 点击通知:导航到对应页面(告警详情/审批/任务详情)
|
||
///
|
||
/// 3. 通知 channel 分级:
|
||
/// - high: 紧急告警、审批请求(振动+声音+全屏)
|
||
/// - default: 一般告警、任务完成
|
||
/// - low: 信息通知
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 开发规范
|
||
|
||
### 9.1 命名规范
|
||
|
||
| 类型 | 规范 | 示例 |
|
||
|------|------|------|
|
||
| 文件名 | snake_case | `chat_repository_impl.dart` |
|
||
| 类名 | PascalCase | `ChatRepositoryImpl` |
|
||
| 变量/方法 | camelCase | `sendMessage()` |
|
||
| 常量 | camelCase | `maxReconnectAttempts` |
|
||
| Provider | camelCase + Provider 后缀 | `chatRepositoryProvider` |
|
||
| 枚举值 | camelCase | `TaskStatus.waitingApproval` |
|
||
|
||
### 9.2 分层规则
|
||
|
||
```
|
||
presentation → domain ← data
|
||
↓ ↑ ↓
|
||
widgets entities models
|
||
providers usecases datasources
|
||
pages repos(抽象) repos(实现)
|
||
```
|
||
|
||
- **domain 层零依赖**:不导入 Flutter、Riverpod、Dio 等任何外部包
|
||
- **data 层实现 domain 接口**:Repository 实现、Model ↔ Entity 转换
|
||
- **presentation 层**:只依赖 domain 层的 entities 和 usecases
|
||
|
||
### 9.3 代码生成
|
||
|
||
```bash
|
||
# freezed + json_serializable + riverpod_generator
|
||
flutter pub run build_runner build --delete-conflicting-outputs
|
||
```
|
||
|
||
每个 `@freezed` 类都需要 `part '文件名.freezed.dart'` 和 `part '文件名.g.dart'`。
|
||
|
||
---
|
||
|
||
## 10. 开发路线图
|
||
|
||
### Phase 1: 基础骨架(Week 1-2)
|
||
- [ ] Flutter 项目初始化(Riverpod + go_router + Dio)
|
||
- [ ] 核心基础设施(DioClient、WebSocketClient、主题)
|
||
- [ ] auth feature(登录页、JWT 存储、自动刷新)
|
||
- [ ] 底部导航 Shell + 空白页面框架
|
||
|
||
### Phase 2: 核心交互(Week 3-4)
|
||
- [ ] chat feature 完整实现(文字对话 + 流式渲染)
|
||
- [ ] 工具执行卡片(显示 Bash 命令和输出)
|
||
- [ ] 审批操作卡片(内联批准/拒绝)
|
||
- [ ] Agent 状态指示器(thinking/executing/waiting)
|
||
|
||
### Phase 3: 运维功能(Week 5-6)
|
||
- [ ] dashboard feature(仪表盘 + 快捷操作)
|
||
- [ ] servers feature(服务器列表 + 详情 + 指标图表)
|
||
- [ ] tasks feature(任务列表 + 创建 + 详情)
|
||
- [ ] 预设快捷任务面板
|
||
|
||
### Phase 4: 语音与通知(Week 7-8)
|
||
- [ ] 语音输入(speech_to_text)
|
||
- [ ] 语音输出(flutter_tts)
|
||
- [ ] FCM 推送集成
|
||
- [ ] 告警中心 + 本地通知
|
||
|
||
### Phase 5: 高级功能(Week 9-10)
|
||
- [ ] terminal feature(远程终端)
|
||
- [ ] approvals feature(审批中心独立页面)
|
||
- [ ] settings feature(引擎切换、通知配置、安全策略)
|
||
- [ ] 离线缓存 + 暗色/亮色主题切换
|
||
|
||
---
|
||
|
||
## 11. pubspec.yaml 参考
|
||
|
||
```yaml
|
||
name: it0_app
|
||
description: IT Operations Intelligent Agent
|
||
|
||
environment:
|
||
sdk: '>=3.2.0 <4.0.0'
|
||
|
||
dependencies:
|
||
flutter:
|
||
sdk: flutter
|
||
|
||
# 状态管理
|
||
flutter_riverpod: ^2.5.0
|
||
riverpod_annotation: ^2.3.0
|
||
|
||
# 路由
|
||
go_router: ^14.0.0
|
||
|
||
# 网络
|
||
dio: ^5.4.0
|
||
web_socket_channel: ^3.0.0
|
||
|
||
# 数据模型
|
||
freezed_annotation: ^2.4.0
|
||
json_annotation: ^4.9.0
|
||
|
||
# 本地存储
|
||
hive: ^4.0.0
|
||
hive_flutter: ^2.0.0
|
||
shared_preferences: ^2.3.0
|
||
|
||
# 语音
|
||
speech_to_text: ^7.0.0
|
||
flutter_tts: ^4.0.0
|
||
|
||
# 推送通知
|
||
firebase_messaging: ^15.0.0
|
||
flutter_local_notifications: ^18.0.0
|
||
|
||
# UI 组件
|
||
fl_chart: ^0.69.0 # 图表
|
||
flutter_xterm: ^4.0.0 # 终端模拟
|
||
flutter_syntax_view: ^4.0.0 # 代码高亮
|
||
shimmer: ^3.0.0 # 加载骨架屏
|
||
lottie: ^3.0.0 # 动画
|
||
|
||
# 工具
|
||
uuid: ^4.4.0
|
||
intl: ^0.19.0
|
||
logger: ^2.4.0
|
||
connectivity_plus: ^6.0.0 # 网络状态检测
|
||
|
||
dev_dependencies:
|
||
flutter_test:
|
||
sdk: flutter
|
||
|
||
# 代码生成
|
||
build_runner: ^2.4.0
|
||
freezed: ^2.5.0
|
||
json_serializable: ^6.8.0
|
||
riverpod_generator: ^2.4.0
|
||
|
||
# Lint
|
||
flutter_lints: ^4.0.0
|
||
|
||
# 测试
|
||
mockito: ^5.4.0
|
||
mocktail: ^1.0.0
|
||
```
|