2464 lines
67 KiB
Markdown
2464 lines
67 KiB
Markdown
|
|
# AG-UI 聊天功能实现计划 v1.3
|
|||
|
|
|
|||
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|||
|
|
>
|
|||
|
|
> **Revision History:**
|
|||
|
|
> - v1.3: 修复 AiDecisionEngine 规则优先级、ChatState.copyWith 空值处理、测试用例对齐
|
|||
|
|
> - v1.2: 修复 AgUiService.onEvent 可变、ChatBloc 初始化、补充测试覆盖
|
|||
|
|
> - v1.1: 修复编译错误、事件映射、职责边界、设计 token、测试覆盖等问题
|
|||
|
|
|
|||
|
|
**Goal:** 实现基于 AG-UI 协议的 AI 聊天功能,包括事件流处理、工具调用、日历卡片渲染
|
|||
|
|
|
|||
|
|
**Architecture:**
|
|||
|
|
- 核心组件:AgUiService 处理事件流、ToolRegistry 管理前端工具
|
|||
|
|
- 数据流:用户消息 → 事件流 → AI 响应 → ToolCall → 执行 → UI 渲染
|
|||
|
|
- Mock 模式使用规则引擎模拟 AI 决策,真实模式对接 SSE
|
|||
|
|
|
|||
|
|
**Tech Stack:** Flutter, Dart, shared_preferences
|
|||
|
|
|
|||
|
|
**Scope:** 本次仅实现 Mock 模式,真实 SSE 对接后续迭代
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 阶段 0: 依赖与脚手架
|
|||
|
|
|
|||
|
|
### Task 0: 添加依赖
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/pubspec.yaml`
|
|||
|
|
|
|||
|
|
**Step 1: 添加依赖**
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
dependencies:
|
|||
|
|
shared_preferences: ^2.2.2
|
|||
|
|
json_annotation: ^4.8.1
|
|||
|
|
|
|||
|
|
dev_dependencies:
|
|||
|
|
json_serializable: ^6.7.1
|
|||
|
|
build_runner: ^2.4.8
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: 安装依赖**
|
|||
|
|
|
|||
|
|
Run: `cd apps && flutter pub get`
|
|||
|
|
|
|||
|
|
Expected: 依赖安装成功
|
|||
|
|
|
|||
|
|
**Step 3: 创建目录结构**
|
|||
|
|
|
|||
|
|
Run: `mkdir -p apps/lib/features/chat/{data/{models,tools,services,ai,repositories},ui/widgets}`
|
|||
|
|
|
|||
|
|
**Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/pubspec.yaml apps/pubspec.lock
|
|||
|
|
git commit -m "chore(chat): add json_annotation and shared_preferences deps"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 阶段 1: 基础框架
|
|||
|
|
|
|||
|
|
### Task 1: 创建 AG-UI 事件模型
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/data/models/ag_ui_event.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 AG-UI 事件基类和子类型**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:json_annotation/json_annotation.dart';
|
|||
|
|
import 'tool_result.dart'; // 引入 UiCard 定义
|
|||
|
|
|
|||
|
|
part 'ag_ui_event.g.dart';
|
|||
|
|
|
|||
|
|
/// Wire protocol 事件类型字符串(与服务端协议对齐)
|
|||
|
|
class AgUiEventTypeWire {
|
|||
|
|
static const runStarted = 'RUN_STARTED';
|
|||
|
|
static const runFinished = 'RUN_FINISHED';
|
|||
|
|
static const runError = 'RUN_ERROR';
|
|||
|
|
static const textMessageStart = 'TEXT_MESSAGE_START';
|
|||
|
|
static const textMessageContent = 'TEXT_MESSAGE_CONTENT';
|
|||
|
|
static const textMessageEnd = 'TEXT_MESSAGE_END';
|
|||
|
|
static const toolCallStart = 'TOOL_CALL_START';
|
|||
|
|
static const toolCallArgs = 'TOOL_CALL_ARGS';
|
|||
|
|
static const toolCallEnd = 'TOOL_CALL_END';
|
|||
|
|
static const toolCallResult = 'TOOL_CALL_RESULT';
|
|||
|
|
static const toolCallError = 'TOOL_CALL_ERROR';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 内部事件类型枚举
|
|||
|
|
enum AgUiEventType {
|
|||
|
|
runStarted,
|
|||
|
|
runFinished,
|
|||
|
|
runError,
|
|||
|
|
textMessageStart,
|
|||
|
|
textMessageContent,
|
|||
|
|
textMessageEnd,
|
|||
|
|
toolCallStart,
|
|||
|
|
toolCallArgs,
|
|||
|
|
toolCallEnd,
|
|||
|
|
toolCallResult,
|
|||
|
|
toolCallError,
|
|||
|
|
unknown,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 事件类型映射工具
|
|||
|
|
class AgUiEventTypeMapper {
|
|||
|
|
static AgUiEventType fromWire(String wireType) {
|
|||
|
|
switch (wireType) {
|
|||
|
|
case AgUiEventTypeWire.runStarted:
|
|||
|
|
return AgUiEventType.runStarted;
|
|||
|
|
case AgUiEventTypeWire.runFinished:
|
|||
|
|
return AgUiEventType.runFinished;
|
|||
|
|
case AgUiEventTypeWire.runError:
|
|||
|
|
return AgUiEventType.runError;
|
|||
|
|
case AgUiEventTypeWire.textMessageStart:
|
|||
|
|
return AgUiEventType.textMessageStart;
|
|||
|
|
case AgUiEventTypeWire.textMessageContent:
|
|||
|
|
return AgUiEventType.textMessageContent;
|
|||
|
|
case AgUiEventTypeWire.textMessageEnd:
|
|||
|
|
return AgUiEventType.textMessageEnd;
|
|||
|
|
case AgUiEventTypeWire.toolCallStart:
|
|||
|
|
return AgUiEventType.toolCallStart;
|
|||
|
|
case AgUiEventTypeWire.toolCallArgs:
|
|||
|
|
return AgUiEventType.toolCallArgs;
|
|||
|
|
case AgUiEventTypeWire.toolCallEnd:
|
|||
|
|
return AgUiEventType.toolCallEnd;
|
|||
|
|
case AgUiEventTypeWire.toolCallResult:
|
|||
|
|
return AgUiEventType.toolCallResult;
|
|||
|
|
case AgUiEventTypeWire.toolCallError:
|
|||
|
|
return AgUiEventType.toolCallError;
|
|||
|
|
default:
|
|||
|
|
return AgUiEventType.unknown;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static String toWire(AgUiEventType type) {
|
|||
|
|
switch (type) {
|
|||
|
|
case AgUiEventType.runStarted:
|
|||
|
|
return AgUiEventTypeWire.runStarted;
|
|||
|
|
case AgUiEventType.runFinished:
|
|||
|
|
return AgUiEventTypeWire.runFinished;
|
|||
|
|
case AgUiEventType.runError:
|
|||
|
|
return AgUiEventTypeWire.runError;
|
|||
|
|
case AgUiEventType.textMessageStart:
|
|||
|
|
return AgUiEventTypeWire.textMessageStart;
|
|||
|
|
case AgUiEventType.textMessageContent:
|
|||
|
|
return AgUiEventTypeWire.textMessageContent;
|
|||
|
|
case AgUiEventType.textMessageEnd:
|
|||
|
|
return AgUiEventTypeWire.textMessageEnd;
|
|||
|
|
case AgUiEventType.toolCallStart:
|
|||
|
|
return AgUiEventTypeWire.toolCallStart;
|
|||
|
|
case AgUiEventType.toolCallArgs:
|
|||
|
|
return AgUiEventTypeWire.toolCallArgs;
|
|||
|
|
case AgUiEventType.toolCallEnd:
|
|||
|
|
return AgUiEventTypeWire.toolCallEnd;
|
|||
|
|
case AgUiEventType.toolCallResult:
|
|||
|
|
return AgUiEventTypeWire.toolCallResult;
|
|||
|
|
case AgUiEventType.toolCallError:
|
|||
|
|
return AgUiEventTypeWire.toolCallError;
|
|||
|
|
case AgUiEventType.unknown:
|
|||
|
|
return 'UNKNOWN';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 基类事件
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class AgUiEvent {
|
|||
|
|
final AgUiEventType type;
|
|||
|
|
final String? timestamp;
|
|||
|
|
|
|||
|
|
AgUiEvent({required this.type, this.timestamp});
|
|||
|
|
|
|||
|
|
factory AgUiEvent.fromJson(Map<String, dynamic> json) {
|
|||
|
|
final wireType = json['type'] as String? ?? 'UNKNOWN';
|
|||
|
|
final type = AgUiEventTypeMapper.fromWire(wireType);
|
|||
|
|
|
|||
|
|
switch (type) {
|
|||
|
|
case AgUiEventType.textMessageStart:
|
|||
|
|
return TextMessageStartEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.textMessageContent:
|
|||
|
|
return TextMessageContentEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.textMessageEnd:
|
|||
|
|
return TextMessageEndEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.toolCallStart:
|
|||
|
|
return ToolCallStartEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.toolCallArgs:
|
|||
|
|
return ToolCallArgsEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.toolCallEnd:
|
|||
|
|
return ToolCallEndEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.toolCallResult:
|
|||
|
|
return ToolCallResultEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.toolCallError:
|
|||
|
|
return ToolCallErrorEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.runStarted:
|
|||
|
|
return RunStartedEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.runFinished:
|
|||
|
|
return RunFinishedEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.runError:
|
|||
|
|
return RunErrorEvent.fromJson(json);
|
|||
|
|
case AgUiEventType.unknown:
|
|||
|
|
default:
|
|||
|
|
return UnknownAgUiEvent(raw: json);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Map<String, dynamic> toJson() => _$AgUiEventToJson(this);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 未知事件(容错处理)
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class UnknownAgUiEvent extends AgUiEvent {
|
|||
|
|
final Map<String, dynamic> raw;
|
|||
|
|
|
|||
|
|
UnknownAgUiEvent({required this.raw})
|
|||
|
|
: super(type: AgUiEventType.unknown);
|
|||
|
|
|
|||
|
|
factory UnknownAgUiEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$UnknownAgUiEventFromJson(json);
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Map<String, dynamic> toJson() => raw;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class RunStartedEvent extends AgUiEvent {
|
|||
|
|
final String threadId;
|
|||
|
|
final String runId;
|
|||
|
|
|
|||
|
|
RunStartedEvent({
|
|||
|
|
required this.threadId,
|
|||
|
|
required this.runId,
|
|||
|
|
}) : super(type: AgUiEventType.runStarted);
|
|||
|
|
|
|||
|
|
factory RunStartedEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$RunStartedEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class RunFinishedEvent extends AgUiEvent {
|
|||
|
|
final String threadId;
|
|||
|
|
final String runId;
|
|||
|
|
|
|||
|
|
RunFinishedEvent({
|
|||
|
|
required this.threadId,
|
|||
|
|
required this.runId,
|
|||
|
|
}) : super(type: AgUiEventType.runFinished);
|
|||
|
|
|
|||
|
|
factory RunFinishedEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$RunFinishedEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class RunErrorEvent extends AgUiEvent {
|
|||
|
|
final String message;
|
|||
|
|
final String? code;
|
|||
|
|
|
|||
|
|
RunErrorEvent({
|
|||
|
|
required this.message,
|
|||
|
|
this.code,
|
|||
|
|
}) : super(type: AgUiEventType.runError);
|
|||
|
|
|
|||
|
|
factory RunErrorEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$RunErrorEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class TextMessageStartEvent extends AgUiEvent {
|
|||
|
|
final String messageId;
|
|||
|
|
final String role;
|
|||
|
|
|
|||
|
|
TextMessageStartEvent({
|
|||
|
|
required this.messageId,
|
|||
|
|
required this.role,
|
|||
|
|
}) : super(type: AgUiEventType.textMessageStart);
|
|||
|
|
|
|||
|
|
factory TextMessageStartEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$TextMessageStartEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class TextMessageContentEvent extends AgUiEvent {
|
|||
|
|
final String messageId;
|
|||
|
|
final String delta;
|
|||
|
|
|
|||
|
|
TextMessageContentEvent({
|
|||
|
|
required this.messageId,
|
|||
|
|
required this.delta,
|
|||
|
|
}) : super(type: AgUiEventType.textMessageContent);
|
|||
|
|
|
|||
|
|
factory TextMessageContentEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$TextMessageContentEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class TextMessageEndEvent extends AgUiEvent {
|
|||
|
|
final String messageId;
|
|||
|
|
|
|||
|
|
TextMessageEndEvent({required this.messageId})
|
|||
|
|
: super(type: AgUiEventType.textMessageEnd);
|
|||
|
|
|
|||
|
|
factory TextMessageEndEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$TextMessageEndEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class ToolCallStartEvent extends AgUiEvent {
|
|||
|
|
final String toolCallId;
|
|||
|
|
final String toolCallName;
|
|||
|
|
final String? parentMessageId;
|
|||
|
|
|
|||
|
|
ToolCallStartEvent({
|
|||
|
|
required this.toolCallId,
|
|||
|
|
required this.toolCallName,
|
|||
|
|
this.parentMessageId,
|
|||
|
|
}) : super(type: AgUiEventType.toolCallStart);
|
|||
|
|
|
|||
|
|
factory ToolCallStartEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$ToolCallStartEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class ToolCallArgsEvent extends AgUiEvent {
|
|||
|
|
final String toolCallId;
|
|||
|
|
final String delta;
|
|||
|
|
|
|||
|
|
ToolCallArgsEvent({
|
|||
|
|
required this.toolCallId,
|
|||
|
|
required this.delta,
|
|||
|
|
}) : super(type: AgUiEventType.toolCallArgs);
|
|||
|
|
|
|||
|
|
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$ToolCallArgsEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class ToolCallEndEvent extends AgUiEvent {
|
|||
|
|
final String toolCallId;
|
|||
|
|
|
|||
|
|
ToolCallEndEvent({required this.toolCallId})
|
|||
|
|
: super(type: AgUiEventType.toolCallEnd);
|
|||
|
|
|
|||
|
|
factory ToolCallEndEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$ToolCallEndEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class ToolCallResultEvent extends AgUiEvent {
|
|||
|
|
final String messageId;
|
|||
|
|
final String toolCallId;
|
|||
|
|
final Map<String, dynamic> result;
|
|||
|
|
final UiCard? ui;
|
|||
|
|
|
|||
|
|
ToolCallResultEvent({
|
|||
|
|
required this.messageId,
|
|||
|
|
required this.toolCallId,
|
|||
|
|
required this.result,
|
|||
|
|
this.ui,
|
|||
|
|
}) : super(type: AgUiEventType.toolCallResult);
|
|||
|
|
|
|||
|
|
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$ToolCallResultEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class ToolCallErrorEvent extends AgUiEvent {
|
|||
|
|
final String toolCallId;
|
|||
|
|
final String error;
|
|||
|
|
final String? code;
|
|||
|
|
|
|||
|
|
ToolCallErrorEvent({
|
|||
|
|
required this.toolCallId,
|
|||
|
|
required this.error,
|
|||
|
|
this.code,
|
|||
|
|
}) : super(type: AgUiEventType.toolCallError);
|
|||
|
|
|
|||
|
|
factory ToolCallErrorEvent.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$ToolCallErrorEventFromJson(json);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UiCard 定义在 tool_result.dart,通过 import 引入
|
|||
|
|
// 此文件顶部需要: import 'tool_result.dart';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: 生成代码**
|
|||
|
|
|
|||
|
|
Run: `cd apps && dart run build_runner build --delete-conflicting-outputs`
|
|||
|
|
|
|||
|
|
Expected: 生成 `ag_ui_event.g.dart`
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/data/models/ag_ui_event.dart
|
|||
|
|
git commit -m "feat(chat): add AG-UI event models with wire protocol mapping"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 2: 创建 Tool Result Schema 模型
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/data/models/tool_result.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 ToolResult 和 UiCard 模型**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:json_annotation/json_annotation.dart';
|
|||
|
|
|
|||
|
|
part 'tool_result.g.dart';
|
|||
|
|
|
|||
|
|
/// 工具执行结果(给 AI 的原始数据)
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class ToolResult {
|
|||
|
|
final String? eventId;
|
|||
|
|
final bool ok;
|
|||
|
|
final String? message;
|
|||
|
|
|
|||
|
|
ToolResult({
|
|||
|
|
this.eventId,
|
|||
|
|
this.ok = true,
|
|||
|
|
this.message,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
factory ToolResult.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$ToolResultFromJson(json);
|
|||
|
|
|
|||
|
|
Map<String, dynamic> toJson() => _$ToolResultToJson(this);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// UI 卡片 Schema(给 UI 渲染)
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class UiCard {
|
|||
|
|
@JsonKey(name: 'type')
|
|||
|
|
final String cardType;
|
|||
|
|
|
|||
|
|
@JsonKey(name: 'version')
|
|||
|
|
final String? schemaVersion;
|
|||
|
|
|
|||
|
|
final Map<String, dynamic> data;
|
|||
|
|
final List<CardAction>? actions;
|
|||
|
|
|
|||
|
|
UiCard({
|
|||
|
|
required this.cardType,
|
|||
|
|
this.schemaVersion = 'v1',
|
|||
|
|
required this.data,
|
|||
|
|
this.actions,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
factory UiCard.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$UiCardFromJson(json);
|
|||
|
|
|
|||
|
|
Map<String, dynamic> toJson() => _$UiCardToJson(this);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 卡片操作按钮
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class CardAction {
|
|||
|
|
final String type;
|
|||
|
|
final String label;
|
|||
|
|
final String? target;
|
|||
|
|
final String? action;
|
|||
|
|
|
|||
|
|
CardAction({
|
|||
|
|
required this.type,
|
|||
|
|
required this.label,
|
|||
|
|
this.target,
|
|||
|
|
this.action,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
factory CardAction.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$CardActionFromJson(json);
|
|||
|
|
|
|||
|
|
Map<String, dynamic> toJson() => _$CardActionToJson(this);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 日历卡片数据
|
|||
|
|
@JsonSerializable()
|
|||
|
|
class CalendarCardData {
|
|||
|
|
final String id;
|
|||
|
|
final String title;
|
|||
|
|
final String? description;
|
|||
|
|
final String startAt;
|
|||
|
|
final String? endAt;
|
|||
|
|
final String? timezone;
|
|||
|
|
final String? location;
|
|||
|
|
final String? color;
|
|||
|
|
final String? sourceType;
|
|||
|
|
|
|||
|
|
CalendarCardData({
|
|||
|
|
required this.id,
|
|||
|
|
required this.title,
|
|||
|
|
this.description,
|
|||
|
|
required this.startAt,
|
|||
|
|
this.endAt,
|
|||
|
|
this.timezone,
|
|||
|
|
this.location,
|
|||
|
|
this.color,
|
|||
|
|
this.sourceType,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
factory CalendarCardData.fromJson(Map<String, dynamic> json) =>
|
|||
|
|
_$CalendarCardDataFromJson(json);
|
|||
|
|
|
|||
|
|
Map<String, dynamic> toJson() => _$CalendarCardDataToJson(this);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: 生成代码**
|
|||
|
|
|
|||
|
|
Run: `cd apps && dart run build_runner build --delete-conflicting-outputs`
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/data/models/tool_result.dart
|
|||
|
|
git commit -m "feat(chat): add ToolResult and UiCard models"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 3: 创建 ChatListItem 模型(独立于 home)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/data/models/chat_list_item.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 ChatListItem 模型**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'tool_result.dart';
|
|||
|
|
|
|||
|
|
enum ChatItemType { message, toolCall, toolResult }
|
|||
|
|
|
|||
|
|
enum MessageSender { user, ai }
|
|||
|
|
|
|||
|
|
enum ToolCallStatus { pending, executing, completed, error }
|
|||
|
|
|
|||
|
|
/// 聊天列表项基类
|
|||
|
|
abstract class ChatListItem {
|
|||
|
|
String get id;
|
|||
|
|
DateTime get timestamp;
|
|||
|
|
ChatItemType get type;
|
|||
|
|
MessageSender get sender;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 文本消息项
|
|||
|
|
class TextMessageItem extends ChatListItem {
|
|||
|
|
@override
|
|||
|
|
final String id;
|
|||
|
|
final String content;
|
|||
|
|
@override
|
|||
|
|
final DateTime timestamp;
|
|||
|
|
@override
|
|||
|
|
final MessageSender sender;
|
|||
|
|
final bool isStreaming;
|
|||
|
|
|
|||
|
|
TextMessageItem({
|
|||
|
|
required this.id,
|
|||
|
|
required this.content,
|
|||
|
|
required this.timestamp,
|
|||
|
|
required this.sender,
|
|||
|
|
this.isStreaming = false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
ChatItemType get type => ChatItemType.message;
|
|||
|
|
|
|||
|
|
TextMessageItem copyWith({
|
|||
|
|
String? id,
|
|||
|
|
String? content,
|
|||
|
|
DateTime? timestamp,
|
|||
|
|
MessageSender? sender,
|
|||
|
|
bool? isStreaming,
|
|||
|
|
}) {
|
|||
|
|
return TextMessageItem(
|
|||
|
|
id: id ?? this.id,
|
|||
|
|
content: content ?? this.content,
|
|||
|
|
timestamp: timestamp ?? this.timestamp,
|
|||
|
|
sender: sender ?? this.sender,
|
|||
|
|
isStreaming: isStreaming ?? this.isStreaming,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 工具调用项(pending 状态)
|
|||
|
|
class ToolCallItem extends ChatListItem {
|
|||
|
|
@override
|
|||
|
|
final String id;
|
|||
|
|
final String callId;
|
|||
|
|
final String toolName;
|
|||
|
|
final Map<String, dynamic> args;
|
|||
|
|
final ToolCallStatus status;
|
|||
|
|
final String? errorMessage;
|
|||
|
|
@override
|
|||
|
|
final DateTime timestamp;
|
|||
|
|
@override
|
|||
|
|
final MessageSender sender;
|
|||
|
|
|
|||
|
|
ToolCallItem({
|
|||
|
|
required this.id,
|
|||
|
|
required this.callId,
|
|||
|
|
required this.toolName,
|
|||
|
|
required this.args,
|
|||
|
|
required this.status,
|
|||
|
|
this.errorMessage,
|
|||
|
|
required this.timestamp,
|
|||
|
|
required this.sender,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
ChatItemType get type => ChatItemType.toolCall;
|
|||
|
|
|
|||
|
|
ToolCallItem copyWith({
|
|||
|
|
String? id,
|
|||
|
|
String? callId,
|
|||
|
|
String? toolName,
|
|||
|
|
Map<String, dynamic>? args,
|
|||
|
|
ToolCallStatus? status,
|
|||
|
|
String? errorMessage,
|
|||
|
|
DateTime? timestamp,
|
|||
|
|
MessageSender? sender,
|
|||
|
|
}) {
|
|||
|
|
return ToolCallItem(
|
|||
|
|
id: id ?? this.id,
|
|||
|
|
callId: callId ?? this.callId,
|
|||
|
|
toolName: toolName ?? this.toolName,
|
|||
|
|
args: args ?? this.args,
|
|||
|
|
status: status ?? this.status,
|
|||
|
|
errorMessage: errorMessage ?? this.errorMessage,
|
|||
|
|
timestamp: timestamp ?? this.timestamp,
|
|||
|
|
sender: sender ?? this.sender,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 工具结果卡片项
|
|||
|
|
class ToolResultItem extends ChatListItem {
|
|||
|
|
@override
|
|||
|
|
final String id;
|
|||
|
|
final String callId;
|
|||
|
|
final UiCard uiCard;
|
|||
|
|
@override
|
|||
|
|
final DateTime timestamp;
|
|||
|
|
@override
|
|||
|
|
final MessageSender sender;
|
|||
|
|
|
|||
|
|
ToolResultItem({
|
|||
|
|
required this.id,
|
|||
|
|
required this.callId,
|
|||
|
|
required this.uiCard,
|
|||
|
|
required this.timestamp,
|
|||
|
|
required this.sender,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
ChatItemType get type => ChatItemType.toolResult;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/data/models/chat_list_item.dart
|
|||
|
|
git commit -m "feat(chat): add ChatListItem models in chat feature"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 4: 创建 ToolRegistry
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/data/tools/tool_registry.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 ToolRegistry**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'dart:convert';
|
|||
|
|
|
|||
|
|
typedef ToolHandler = Future<Map<String, dynamic>> Function(
|
|||
|
|
Map<String, dynamic> args,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
class ToolDefinition {
|
|||
|
|
final String name;
|
|||
|
|
final String description;
|
|||
|
|
final Map<String, dynamic> parameters;
|
|||
|
|
final ToolHandler handler;
|
|||
|
|
|
|||
|
|
ToolDefinition({
|
|||
|
|
required this.name,
|
|||
|
|
required this.description,
|
|||
|
|
required this.parameters,
|
|||
|
|
required this.handler,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class ToolRegistry {
|
|||
|
|
static final Map<String, ToolDefinition> _tools = {};
|
|||
|
|
static bool _initialized = false;
|
|||
|
|
|
|||
|
|
static void initialize() {
|
|||
|
|
if (_initialized) return;
|
|||
|
|
|
|||
|
|
_tools['create_calendar_event'] = ToolDefinition(
|
|||
|
|
name: 'create_calendar_event',
|
|||
|
|
description: '创建一个日历事件或待办事项',
|
|||
|
|
parameters: {
|
|||
|
|
'type': 'object',
|
|||
|
|
'properties': {
|
|||
|
|
'title': {
|
|||
|
|
'type': 'string',
|
|||
|
|
'description': '事件标题',
|
|||
|
|
'minLength': 1,
|
|||
|
|
'maxLength': 100,
|
|||
|
|
},
|
|||
|
|
'description': {'type': 'string', 'description': '事件描述'},
|
|||
|
|
'startAt': {
|
|||
|
|
'type': 'string',
|
|||
|
|
'format': 'date-time',
|
|||
|
|
'description': '开始时间 (ISO8601)',
|
|||
|
|
},
|
|||
|
|
'endAt': {
|
|||
|
|
'type': 'string',
|
|||
|
|
'format': 'date-time',
|
|||
|
|
'description': '结束时间 (ISO8601)',
|
|||
|
|
},
|
|||
|
|
'timezone': {
|
|||
|
|
'type': 'string',
|
|||
|
|
'default': 'Asia/Shanghai',
|
|||
|
|
},
|
|||
|
|
'location': {'type': 'string'},
|
|||
|
|
'notes': {'type': 'string'},
|
|||
|
|
},
|
|||
|
|
'required': ['title', 'startAt'],
|
|||
|
|
},
|
|||
|
|
handler: _handleCreateCalendarEvent,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
_initialized = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static Future<Map<String, dynamic>> _handleCreateCalendarEvent(
|
|||
|
|
Map<String, dynamic> args,
|
|||
|
|
) async {
|
|||
|
|
final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}';
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'eventId': eventId,
|
|||
|
|
'ok': true,
|
|||
|
|
'message': '日程已创建',
|
|||
|
|
'title': args['title'],
|
|||
|
|
'description': args['description'],
|
|||
|
|
'startAt': args['startAt'],
|
|||
|
|
'endAt': args['endAt'],
|
|||
|
|
'timezone': args['timezone'] ?? 'Asia/Shanghai',
|
|||
|
|
'location': args['location'],
|
|||
|
|
'color': '#4F46E5',
|
|||
|
|
'sourceType': 'agentGenerated',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static ToolDefinition? getTool(String name) => _tools[name];
|
|||
|
|
|
|||
|
|
static List<ToolDefinition> getAllTools() => _tools.values.toList();
|
|||
|
|
|
|||
|
|
static Future<Map<String, dynamic>> execute(
|
|||
|
|
String toolName,
|
|||
|
|
Map<String, dynamic> args,
|
|||
|
|
) async {
|
|||
|
|
final tool = _tools[toolName];
|
|||
|
|
if (tool == null) {
|
|||
|
|
throw ToolNotFoundException('Tool not found: $toolName');
|
|||
|
|
}
|
|||
|
|
return tool.handler(args);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 校验工具参数
|
|||
|
|
static ToolValidationResult validateArgs(
|
|||
|
|
String toolName,
|
|||
|
|
Map<String, dynamic> args,
|
|||
|
|
) {
|
|||
|
|
final tool = _tools[toolName];
|
|||
|
|
if (tool == null) {
|
|||
|
|
return ToolValidationResult(
|
|||
|
|
ok: false,
|
|||
|
|
error: 'Tool not found: $toolName',
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final required = tool.parameters['required'] as List<dynamic>? ?? [];
|
|||
|
|
final missing = <String>[];
|
|||
|
|
|
|||
|
|
for (final field in required) {
|
|||
|
|
if (!args.containsKey(field) || args[field] == null) {
|
|||
|
|
missing.add(field as String);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (missing.isNotEmpty) {
|
|||
|
|
return ToolValidationResult(
|
|||
|
|
ok: false,
|
|||
|
|
error: 'Missing required fields: ${missing.join(', ')}',
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ToolValidationResult(ok: true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class ToolNotFoundException implements Exception {
|
|||
|
|
final String message;
|
|||
|
|
ToolNotFoundException(this.message);
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
String toString() => 'ToolNotFoundException: $message';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class ToolValidationResult {
|
|||
|
|
final bool ok;
|
|||
|
|
final String? error;
|
|||
|
|
|
|||
|
|
ToolValidationResult({required this.ok, this.error});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/data/tools/tool_registry.dart
|
|||
|
|
git commit -m "feat(chat): add ToolRegistry with validation"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 阶段 2: Mock 实现
|
|||
|
|
|
|||
|
|
### Task 5: 创建 AiDecisionEngine 规则引擎
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/data/ai/ai_decision_engine.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 AiDecisionEngine**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'dart:convert';
|
|||
|
|
|
|||
|
|
enum Intent {
|
|||
|
|
createEvent,
|
|||
|
|
searchEvent,
|
|||
|
|
unknown,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class AiDecisionEngine {
|
|||
|
|
/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent)
|
|||
|
|
static final List<(_IntentPattern, Intent)> _orderedPatterns = [
|
|||
|
|
// 搜索意图优先(避免被"日程"等词提前匹配)
|
|||
|
|
(RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent),
|
|||
|
|
// 创建意图(需要更明确的动词)
|
|||
|
|
(RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent),
|
|||
|
|
// 时间相关(通常伴随创建意图)
|
|||
|
|
(RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), Intent.createEvent),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/// 匹配用户意图(按优先级顺序匹配)
|
|||
|
|
Intent matchIntent(String text) {
|
|||
|
|
for (final (pattern, intent) in _orderedPatterns) {
|
|||
|
|
if (pattern.hasMatch(text)) {
|
|||
|
|
return intent;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return Intent.unknown;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 从文本中提取事件参数(仅当明确有创建意图时返回)
|
|||
|
|
Map<String, dynamic>? tryExtractEventArgs(String text) {
|
|||
|
|
// 只有明确的创建意图才提取参数
|
|||
|
|
if (matchIntent(text) != Intent.createEvent) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final args = <String, dynamic>{};
|
|||
|
|
|
|||
|
|
// 提取标题
|
|||
|
|
final titleMatch = RegExp(r'提醒(.+?)(?:明天|今天|几点|$)').firstMatch(text);
|
|||
|
|
if (titleMatch != null) {
|
|||
|
|
args['title'] = titleMatch.group(1)?.trim() ?? text;
|
|||
|
|
} else {
|
|||
|
|
// 如果没有"提醒"前缀,使用整段文本作为标题(但至少需要有时间)
|
|||
|
|
if (RegExp(r'\d{1,2}[:点]|\d{1,2}点').hasMatch(text)) {
|
|||
|
|
args['title'] = text.replaceAll(RegExp(r'\d{1,2}[:点]\d{0,2}|明天|今天|后天'), '').trim();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 必须有标题才返回
|
|||
|
|
if (args['title'] == null || (args['title'] as String).isEmpty) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 提取时间
|
|||
|
|
final timeMatch = RegExp(r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?').firstMatch(text);
|
|||
|
|
if (timeMatch != null) {
|
|||
|
|
final dayStr = timeMatch.group(1) ?? '今天';
|
|||
|
|
final hour = int.parse(timeMatch.group(2)!);
|
|||
|
|
final minute = int.parse(timeMatch.group(3) ?? '0');
|
|||
|
|
|
|||
|
|
final now = DateTime.now();
|
|||
|
|
DateTime startAt;
|
|||
|
|
switch (dayStr) {
|
|||
|
|
case '明天':
|
|||
|
|
startAt = DateTime(now.year, now.month, now.day + 1, hour, minute);
|
|||
|
|
break;
|
|||
|
|
case '后天':
|
|||
|
|
startAt = DateTime(now.year, now.month, now.day + 2, hour, minute);
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
startAt = DateTime(now.year, now.month, now.day, hour, minute);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
args['startAt'] = startAt.toIso8601String();
|
|||
|
|
args['timezone'] = 'Asia/Shanghai';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 必须有 startAt 才返回
|
|||
|
|
if (!args.containsKey('startAt')) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return args;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool shouldTriggerToolCall(String text) {
|
|||
|
|
final intent = matchIntent(text);
|
|||
|
|
return intent == Intent.createEvent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Map<String, dynamic>? getToolCallArgs(String text) {
|
|||
|
|
if (!shouldTriggerToolCall(text)) return null;
|
|||
|
|
return tryExtractEventArgs(text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 检查是否为强制触发(调试用)
|
|||
|
|
/// 格式:#tool:create_calendar_event {"title": "test"}
|
|||
|
|
ForceTriggerResult? tryForceTrigger(String text) {
|
|||
|
|
final match = RegExp(r'#tool:(\w+)\s*(\{.*\})?').firstMatch(text);
|
|||
|
|
if (match == null) return null;
|
|||
|
|
|
|||
|
|
final toolName = match.group(1);
|
|||
|
|
final argsJson = match.group(2);
|
|||
|
|
|
|||
|
|
Map<String, dynamic>? args;
|
|||
|
|
if (argsJson != null) {
|
|||
|
|
try {
|
|||
|
|
args = jsonDecode(argsJson) as Map<String, dynamic>;
|
|||
|
|
} catch (_) {
|
|||
|
|
args = {};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ForceTriggerResult(toolName: toolName!, args: args ?? {});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class ForceTriggerResult {
|
|||
|
|
final String toolName;
|
|||
|
|
final Map<String, dynamic> args;
|
|||
|
|
|
|||
|
|
ForceTriggerResult({required this.toolName, required this.args});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/data/ai/ai_decision_engine.dart
|
|||
|
|
git commit -m "feat(chat): add AiDecisionEngine with force trigger support"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 6: 创建 AgUiService Mock 实现
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/data/services/ag_ui_service.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 AgUiService**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'dart:async';
|
|||
|
|
import 'dart:convert';
|
|||
|
|
|
|||
|
|
import 'package:social_app/core/config/env.dart';
|
|||
|
|
import '../models/ag_ui_event.dart';
|
|||
|
|
import '../models/tool_result.dart';
|
|||
|
|
import '../tools/tool_registry.dart';
|
|||
|
|
import '../ai/ai_decision_engine.dart';
|
|||
|
|
|
|||
|
|
typedef EventCallback = void Function(AgUiEvent event);
|
|||
|
|
|
|||
|
|
class AgUiService {
|
|||
|
|
EventCallback onEvent; // 非 final,允许后续绑定
|
|||
|
|
final AiDecisionEngine _decisionEngine = AiDecisionEngine();
|
|||
|
|
|
|||
|
|
String? _currentRunId;
|
|||
|
|
|
|||
|
|
AgUiService({required this.onEvent}) {
|
|||
|
|
ToolRegistry.initialize();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> sendMessage(String content) async {
|
|||
|
|
if (Env.isMockApi) {
|
|||
|
|
await _mockEventStream(content);
|
|||
|
|
} else {
|
|||
|
|
// 真实模式暂未实现,降级到 mock 并提示
|
|||
|
|
onEvent(RunErrorEvent(
|
|||
|
|
message: 'Real mode not implemented, please enable MOCK_API=true',
|
|||
|
|
code: 'NOT_IMPLEMENTED',
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _mockEventStream(String content) async {
|
|||
|
|
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
|
|||
|
|
_currentRunId = runId;
|
|||
|
|
|
|||
|
|
// RunStarted
|
|||
|
|
onEvent(RunStartedEvent(
|
|||
|
|
threadId: 'thread_default',
|
|||
|
|
runId: runId,
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
// 模拟 AI 思考延迟
|
|||
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|||
|
|
|
|||
|
|
// 检查强制触发
|
|||
|
|
final forceTrigger = _decisionEngine.tryForceTrigger(content);
|
|||
|
|
if (forceTrigger != null) {
|
|||
|
|
await _mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
|
|||
|
|
} else if (_decisionEngine.shouldTriggerToolCall(content)) {
|
|||
|
|
await _mockToolCallFlow(content);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AI 回复文本
|
|||
|
|
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
|||
|
|
onEvent(TextMessageStartEvent(
|
|||
|
|
messageId: messageId,
|
|||
|
|
role: 'assistant',
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
final replies = _generateReplies(content);
|
|||
|
|
for (final chunk in replies) {
|
|||
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|||
|
|
onEvent(TextMessageContentEvent(
|
|||
|
|
messageId: messageId,
|
|||
|
|
delta: chunk,
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|||
|
|
onEvent(TextMessageEndEvent(messageId: messageId));
|
|||
|
|
|
|||
|
|
// RunFinished
|
|||
|
|
onEvent(RunFinishedEvent(
|
|||
|
|
threadId: 'thread_default',
|
|||
|
|
runId: runId,
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _mockToolCallFlow(String content) async {
|
|||
|
|
final args = _decisionEngine.getToolCallArgs(content) ?? {};
|
|||
|
|
await _mockToolCallFlowWithArgs('create_calendar_event', args);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _mockToolCallFlowWithArgs(
|
|||
|
|
String toolName,
|
|||
|
|
Map<String, dynamic> args,
|
|||
|
|
) async {
|
|||
|
|
final toolCallId = 'call_${DateTime.now().millisecondsSinceEpoch}';
|
|||
|
|
|
|||
|
|
// ToolCallStart
|
|||
|
|
onEvent(ToolCallStartEvent(
|
|||
|
|
toolCallId: toolCallId,
|
|||
|
|
toolCallName: toolName,
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
// ToolCallArgs (使用标准 JSON)
|
|||
|
|
final argsJson = jsonEncode(args);
|
|||
|
|
await Future.delayed(const Duration(milliseconds: 200));
|
|||
|
|
onEvent(ToolCallArgsEvent(
|
|||
|
|
toolCallId: toolCallId,
|
|||
|
|
delta: argsJson,
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
// ToolCallEnd
|
|||
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|||
|
|
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
|
|||
|
|
|
|||
|
|
// 校验参数
|
|||
|
|
final validation = ToolRegistry.validateArgs(toolName, args);
|
|||
|
|
if (!validation.ok) {
|
|||
|
|
onEvent(ToolCallErrorEvent(
|
|||
|
|
toolCallId: toolCallId,
|
|||
|
|
error: validation.error ?? 'Validation failed',
|
|||
|
|
code: 'VALIDATION_ERROR',
|
|||
|
|
));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 执行工具
|
|||
|
|
await Future.delayed(const Duration(milliseconds: 300));
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
final result = await ToolRegistry.execute(toolName, args);
|
|||
|
|
|
|||
|
|
// 构建 UI Card
|
|||
|
|
final uiCard = _buildUiCard(toolName, result);
|
|||
|
|
|
|||
|
|
// ToolCallResult
|
|||
|
|
onEvent(ToolCallResultEvent(
|
|||
|
|
messageId: _currentRunId ?? '',
|
|||
|
|
toolCallId: toolCallId,
|
|||
|
|
result: result,
|
|||
|
|
ui: uiCard,
|
|||
|
|
));
|
|||
|
|
} catch (e) {
|
|||
|
|
onEvent(ToolCallErrorEvent(
|
|||
|
|
toolCallId: toolCallId,
|
|||
|
|
error: e.toString(),
|
|||
|
|
code: 'EXECUTION_ERROR',
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
UiCard _buildUiCard(String toolName, Map<String, dynamic> result) {
|
|||
|
|
if (toolName == 'create_calendar_event') {
|
|||
|
|
return UiCard(
|
|||
|
|
cardType: 'calendar_card.v1',
|
|||
|
|
schemaVersion: 'v1',
|
|||
|
|
data: {
|
|||
|
|
'id': result['eventId'],
|
|||
|
|
'title': result['title'],
|
|||
|
|
'description': result['description'],
|
|||
|
|
'startAt': result['startAt'],
|
|||
|
|
'endAt': result['endAt'],
|
|||
|
|
'timezone': result['timezone'],
|
|||
|
|
'location': result['location'],
|
|||
|
|
'color': result['color'],
|
|||
|
|
'sourceType': result['sourceType'],
|
|||
|
|
},
|
|||
|
|
actions: [
|
|||
|
|
CardAction(type: 'open', label: '打开', target: 'calendar/${result['eventId']}'),
|
|||
|
|
CardAction(type: 'edit', label: '编辑', action: 'edit_event'),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 默认返回通用卡片
|
|||
|
|
return UiCard(
|
|||
|
|
cardType: 'generic_card.v1',
|
|||
|
|
data: result,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 生成回复文本(返回 List<String> 支持流式)
|
|||
|
|
List<String> _generateReplies(String content) {
|
|||
|
|
if (_decisionEngine.shouldTriggerToolCall(content)) {
|
|||
|
|
return ['好的,', '我已为你创建日程。'];
|
|||
|
|
}
|
|||
|
|
return ['收到,', '有什么可以帮你的?'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> reconnect() async {
|
|||
|
|
// TODO: 实现重连逻辑
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/data/services/ag_ui_service.dart
|
|||
|
|
git commit -m "feat(chat): add AgUiService with mock event stream"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 阶段 3: UI 渲染
|
|||
|
|
|
|||
|
|
### Task 7: 创建 UiSchemaRenderer
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 UiSchemaRenderer(使用设计 token)**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:social_app/core/theme/design_tokens.dart';
|
|||
|
|
import '../../data/models/tool_result.dart';
|
|||
|
|
|
|||
|
|
class UiSchemaRenderer {
|
|||
|
|
static Widget render(UiCard card) {
|
|||
|
|
switch (card.cardType) {
|
|||
|
|
case 'calendar_card.v1':
|
|||
|
|
return _renderCalendarCard(card);
|
|||
|
|
case 'error_card.v1':
|
|||
|
|
return _renderErrorCard(card);
|
|||
|
|
default:
|
|||
|
|
return _renderUnknownCard(card);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static Widget _renderCalendarCard(UiCard card) {
|
|||
|
|
final data = CalendarCardData.fromJson(card.data);
|
|||
|
|
final color = data.color != null
|
|||
|
|
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
|
|||
|
|
: AppColors.blue500;
|
|||
|
|
|
|||
|
|
return Container(
|
|||
|
|
width: double.infinity,
|
|||
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: AppColors.white,
|
|||
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|||
|
|
boxShadow: [
|
|||
|
|
BoxShadow(
|
|||
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|||
|
|
blurRadius: 8,
|
|||
|
|
offset: const Offset(0, 2),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
child: Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
Container(
|
|||
|
|
height: 4,
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: color,
|
|||
|
|
borderRadius: const BorderRadius.vertical(
|
|||
|
|
top: Radius.circular(2),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: AppSpacing.md),
|
|||
|
|
// Source type
|
|||
|
|
Row(
|
|||
|
|
children: [
|
|||
|
|
const Icon(Icons.auto_awesome, size: 14, color: AppColors.slate500),
|
|||
|
|
const SizedBox(width: AppSpacing.xs),
|
|||
|
|
Text(
|
|||
|
|
'AI生成',
|
|||
|
|
style: const TextStyle(fontSize: 12, color: AppColors.slate500),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: AppSpacing.xs),
|
|||
|
|
// Time
|
|||
|
|
Text(
|
|||
|
|
_formatTime(data.startAt, data.endAt),
|
|||
|
|
style: const TextStyle(fontSize: 12, color: AppColors.slate500),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: AppSpacing.sm),
|
|||
|
|
// Title
|
|||
|
|
Text(
|
|||
|
|
data.title,
|
|||
|
|
style: const TextStyle(
|
|||
|
|
fontSize: 16,
|
|||
|
|
fontWeight: FontWeight.w500,
|
|||
|
|
color: AppColors.slate900,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
if (data.description != null) ...[
|
|||
|
|
const SizedBox(height: AppSpacing.xs),
|
|||
|
|
Text(
|
|||
|
|
data.description!,
|
|||
|
|
style: const TextStyle(fontSize: 14, color: AppColors.slate600),
|
|||
|
|
maxLines: 2,
|
|||
|
|
overflow: TextOverflow.ellipsis,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
if (data.location != null) ...[
|
|||
|
|
const SizedBox(height: AppSpacing.sm),
|
|||
|
|
Row(
|
|||
|
|
children: [
|
|||
|
|
const Icon(Icons.location_on_outlined, size: 14, color: AppColors.slate500),
|
|||
|
|
const SizedBox(width: AppSpacing.xs),
|
|||
|
|
Text(
|
|||
|
|
data.location!,
|
|||
|
|
style: const TextStyle(fontSize: 13, color: AppColors.slate600),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
// Actions
|
|||
|
|
if (card.actions != null && card.actions!.isNotEmpty) ...[
|
|||
|
|
const SizedBox(height: AppSpacing.md),
|
|||
|
|
const Divider(height: 1),
|
|||
|
|
const SizedBox(height: AppSpacing.sm),
|
|||
|
|
Row(
|
|||
|
|
children: card.actions!.map((action) {
|
|||
|
|
return Padding(
|
|||
|
|
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
|||
|
|
child: TextButton(
|
|||
|
|
onPressed: () => _handleAction(action),
|
|||
|
|
style: TextButton.styleFrom(
|
|||
|
|
padding: const EdgeInsets.symmetric(
|
|||
|
|
horizontal: AppSpacing.sm,
|
|||
|
|
vertical: AppSpacing.xs,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
child: Text(action.label),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}).toList(),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static Widget _renderErrorCard(UiCard card) {
|
|||
|
|
return Container(
|
|||
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: AppColors.red50,
|
|||
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|||
|
|
border: Border.all(color: AppColors.red200),
|
|||
|
|
),
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
const Icon(Icons.error_outline, color: AppColors.red600),
|
|||
|
|
const SizedBox(width: AppSpacing.sm),
|
|||
|
|
Expanded(
|
|||
|
|
child: Text(
|
|||
|
|
card.data['message'] ?? '发生错误',
|
|||
|
|
style: const TextStyle(color: AppColors.red600),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static Widget _renderUnknownCard(UiCard card) {
|
|||
|
|
return Container(
|
|||
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: AppColors.slate100,
|
|||
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|||
|
|
),
|
|||
|
|
child: Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
'Unknown card type: ${card.cardType}',
|
|||
|
|
style: const TextStyle(color: AppColors.slate600),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: AppSpacing.sm),
|
|||
|
|
SelectableText(
|
|||
|
|
card.data.toString(),
|
|||
|
|
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static String _formatTime(String startAt, String? endAt) {
|
|||
|
|
final start = DateTime.parse(startAt);
|
|||
|
|
final startStr = '${start.month}月${start.day}日 ${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}';
|
|||
|
|
|
|||
|
|
if (endAt != null) {
|
|||
|
|
final end = DateTime.parse(endAt);
|
|||
|
|
final endStr = '${end.hour.toString().padLeft(2, '0')}:${end.minute.toString().padLeft(2, '0')}';
|
|||
|
|
return '$startStr - $endStr';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return startStr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static void _handleAction(CardAction action) {
|
|||
|
|
// TODO: 实现 action 处理
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart
|
|||
|
|
git commit -m "feat(chat): add UiSchemaRenderer with design tokens"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 阶段 4: 集成
|
|||
|
|
|
|||
|
|
### Task 8: 创建 ChatBloc(状态管理)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 ChatBloc**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'dart:convert';
|
|||
|
|
|
|||
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
|
|
import '../../data/models/ag_ui_event.dart';
|
|||
|
|
import '../../data/models/chat_list_item.dart';
|
|||
|
|
import '../../data/models/tool_result.dart';
|
|||
|
|
import '../../data/services/ag_ui_service.dart';
|
|||
|
|
|
|||
|
|
class ChatBloc extends Cubit<ChatState> {
|
|||
|
|
final AgUiService _service;
|
|||
|
|
final Map<String, String> _pendingArgsBuffer = {};
|
|||
|
|
|
|||
|
|
ChatBloc({AgUiService? service})
|
|||
|
|
: _service = service ?? AgUiService(onEvent: (_) {}), // 默认空回调
|
|||
|
|
super(const ChatState()) {
|
|||
|
|
_service.onEvent = _handleEvent; // 后续绑定实际处理器
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleEvent(AgUiEvent event) {
|
|||
|
|
switch (event.type) {
|
|||
|
|
case AgUiEventType.runStarted:
|
|||
|
|
emit(state.copyWith(isLoading: false));
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.runFinished:
|
|||
|
|
emit(state.copyWith(isLoading: false));
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.runError:
|
|||
|
|
final e = event as RunErrorEvent;
|
|||
|
|
emit(state.copyWith(
|
|||
|
|
isLoading: false,
|
|||
|
|
error: e.message,
|
|||
|
|
));
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.textMessageStart:
|
|||
|
|
_handleTextMessageStart(event as TextMessageStartEvent);
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.textMessageContent:
|
|||
|
|
_handleTextMessageContent(event as TextMessageContentEvent);
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.textMessageEnd:
|
|||
|
|
_handleTextMessageEnd(event as TextMessageEndEvent);
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.toolCallStart:
|
|||
|
|
_handleToolCallStart(event as ToolCallStartEvent);
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.toolCallArgs:
|
|||
|
|
_handleToolCallArgs(event as ToolCallArgsEvent);
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.toolCallEnd:
|
|||
|
|
_handleToolCallEnd(event as ToolCallEndEvent);
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.toolCallResult:
|
|||
|
|
_handleToolCallResult(event as ToolCallResultEvent);
|
|||
|
|
break;
|
|||
|
|
case AgUiEventType.toolCallError:
|
|||
|
|
_handleToolCallError(event as ToolCallErrorEvent);
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleTextMessageStart(TextMessageStartEvent event) {
|
|||
|
|
final newItems = [...state.items];
|
|||
|
|
newItems.add(TextMessageItem(
|
|||
|
|
id: event.messageId,
|
|||
|
|
content: '',
|
|||
|
|
timestamp: DateTime.now(),
|
|||
|
|
sender: MessageSender.ai,
|
|||
|
|
isStreaming: true,
|
|||
|
|
));
|
|||
|
|
emit(state.copyWith(items: newItems, currentMessageId: event.messageId));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleTextMessageContent(TextMessageContentEvent event) {
|
|||
|
|
final index = state.items.indexWhere((item) => item.id == event.messageId);
|
|||
|
|
if (index >= 0) {
|
|||
|
|
final item = state.items[index] as TextMessageItem;
|
|||
|
|
final newItems = [...state.items];
|
|||
|
|
newItems[index] = item.copyWith(
|
|||
|
|
content: item.content + event.delta,
|
|||
|
|
isStreaming: true,
|
|||
|
|
);
|
|||
|
|
emit(state.copyWith(items: newItems));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleTextMessageEnd(TextMessageEndEvent event) {
|
|||
|
|
final index = state.items.indexWhere((item) => item.id == event.messageId);
|
|||
|
|
if (index >= 0) {
|
|||
|
|
final item = state.items[index] as TextMessageItem;
|
|||
|
|
final newItems = [...state.items];
|
|||
|
|
newItems[index] = item.copyWith(isStreaming: false);
|
|||
|
|
emit(state.copyWith(items: newItems, currentMessageId: null));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleToolCallStart(ToolCallStartEvent event) {
|
|||
|
|
final newItems = [...state.items];
|
|||
|
|
newItems.add(ToolCallItem(
|
|||
|
|
id: event.toolCallId,
|
|||
|
|
callId: event.toolCallId,
|
|||
|
|
toolName: event.toolCallName,
|
|||
|
|
args: {},
|
|||
|
|
status: ToolCallStatus.pending,
|
|||
|
|
timestamp: DateTime.now(),
|
|||
|
|
sender: MessageSender.ai,
|
|||
|
|
));
|
|||
|
|
_pendingArgsBuffer[event.toolCallId] = '';
|
|||
|
|
emit(state.copyWith(items: newItems));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleToolCallArgs(ToolCallArgsEvent event) {
|
|||
|
|
_pendingArgsBuffer[event.toolCallId] =
|
|||
|
|
(_pendingArgsBuffer[event.toolCallId] ?? '') + event.delta;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleToolCallEnd(ToolCallEndEvent event) {
|
|||
|
|
final index = state.items.indexWhere((item) => item.id == event.toolCallId);
|
|||
|
|
if (index >= 0) {
|
|||
|
|
final item = state.items[index] as ToolCallItem;
|
|||
|
|
final argsJson = _pendingArgsBuffer[event.toolCallId] ?? '{}';
|
|||
|
|
|
|||
|
|
Map<String, dynamic> args;
|
|||
|
|
try {
|
|||
|
|
args = jsonDecode(argsJson) as Map<String, dynamic>;
|
|||
|
|
} catch (_) {
|
|||
|
|
args = {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final newItems = [...state.items];
|
|||
|
|
newItems[index] = item.copyWith(
|
|||
|
|
args: args,
|
|||
|
|
status: ToolCallStatus.executing,
|
|||
|
|
);
|
|||
|
|
_pendingArgsBuffer.remove(event.toolCallId);
|
|||
|
|
emit(state.copyWith(items: newItems));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleToolCallResult(ToolCallResultEvent event) {
|
|||
|
|
if (event.ui == null) return;
|
|||
|
|
|
|||
|
|
final index = state.items.indexWhere((item) => item.id == event.toolCallId);
|
|||
|
|
if (index >= 0) {
|
|||
|
|
final newItems = [...state.items];
|
|||
|
|
// 移除 pending tool call,添加 result card
|
|||
|
|
newItems.removeAt(index);
|
|||
|
|
newItems.add(ToolResultItem(
|
|||
|
|
id: 'result_${event.toolCallId}',
|
|||
|
|
callId: event.toolCallId,
|
|||
|
|
uiCard: event.ui!,
|
|||
|
|
timestamp: DateTime.now(),
|
|||
|
|
sender: MessageSender.ai,
|
|||
|
|
));
|
|||
|
|
emit(state.copyWith(items: newItems));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _handleToolCallError(ToolCallErrorEvent event) {
|
|||
|
|
final index = state.items.indexWhere((item) => item.id == event.toolCallId);
|
|||
|
|
if (index >= 0) {
|
|||
|
|
final item = state.items[index] as ToolCallItem;
|
|||
|
|
final newItems = [...state.items];
|
|||
|
|
newItems[index] = item.copyWith(
|
|||
|
|
status: ToolCallStatus.error,
|
|||
|
|
errorMessage: event.error,
|
|||
|
|
);
|
|||
|
|
emit(state.copyWith(items: newItems, error: event.error));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> sendMessage(String content) async {
|
|||
|
|
if (content.trim().isEmpty) return;
|
|||
|
|
|
|||
|
|
// 添加用户消息
|
|||
|
|
final userMessage = TextMessageItem(
|
|||
|
|
id: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
|||
|
|
content: content,
|
|||
|
|
timestamp: DateTime.now(),
|
|||
|
|
sender: MessageSender.user,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
emit(state.copyWith(
|
|||
|
|
items: [...state.items, userMessage],
|
|||
|
|
isLoading: true,
|
|||
|
|
error: null,
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
await _service.sendMessage(content);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void clearError() {
|
|||
|
|
emit(state.copyWith(error: null));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class ChatState {
|
|||
|
|
final List<ChatListItem> items;
|
|||
|
|
final bool isLoading;
|
|||
|
|
final String? currentMessageId;
|
|||
|
|
final String? error;
|
|||
|
|
|
|||
|
|
const ChatState({
|
|||
|
|
this.items = const [],
|
|||
|
|
this.isLoading = false,
|
|||
|
|
this.currentMessageId,
|
|||
|
|
this.error,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
static const _unset = Object();
|
|||
|
|
|
|||
|
|
ChatState copyWith({
|
|||
|
|
List<ChatListItem>? items,
|
|||
|
|
bool? isLoading,
|
|||
|
|
Object? currentMessageId = _unset,
|
|||
|
|
Object? error = _unset,
|
|||
|
|
}) {
|
|||
|
|
return ChatState(
|
|||
|
|
items: items ?? this.items,
|
|||
|
|
isLoading: isLoading ?? this.isLoading,
|
|||
|
|
currentMessageId: currentMessageId == _unset
|
|||
|
|
? this.currentMessageId
|
|||
|
|
: currentMessageId as String?,
|
|||
|
|
error: error == _unset
|
|||
|
|
? this.error
|
|||
|
|
: error as String?,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/presentation/bloc/chat_bloc.dart
|
|||
|
|
git commit -m "feat(chat): add ChatBloc for state management"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 9: 更新 HomeScreen 集成 ChatBloc
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/lib/features/home/ui/screens/home_screen.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 添加 ChatBloc 集成**
|
|||
|
|
|
|||
|
|
在现有 HomeScreen 中添加 BlocProvider 和事件处理逻辑,保持现有 UI 结构。
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 在文件顶部添加 import
|
|||
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
|
|
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
|||
|
|
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
|
|||
|
|
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
|||
|
|
import 'package:social_app/shared/widgets/toast.dart';
|
|||
|
|
|
|||
|
|
// 在 build 方法中包裹 BlocProvider
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return BlocProvider(
|
|||
|
|
create: (context) => ChatBloc(),
|
|||
|
|
child: BlocConsumer<ChatBloc, ChatState>(
|
|||
|
|
listener: (context, state) {
|
|||
|
|
if (state.error != null) {
|
|||
|
|
Toast.show(context, state.error!, type: ToastType.error);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
builder: (context, state) {
|
|||
|
|
return Scaffold(
|
|||
|
|
// ... 现有 UI
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新 _buildChatArea 方法
|
|||
|
|
Widget _buildChatArea(BuildContext context) {
|
|||
|
|
final chatState = context.watch<ChatBloc>().state;
|
|||
|
|
final items = chatState.items;
|
|||
|
|
|
|||
|
|
return ListView.builder(
|
|||
|
|
controller: _scrollController,
|
|||
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|||
|
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg),
|
|||
|
|
itemCount: items.length + (chatState.isLoading ? 1 : 0),
|
|||
|
|
itemBuilder: (context, index) {
|
|||
|
|
if (chatState.isLoading && index == items.length) {
|
|||
|
|
return _buildLoadingIndicator();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final item = items[index];
|
|||
|
|
|
|||
|
|
switch (item.type) {
|
|||
|
|
case ChatItemType.message:
|
|||
|
|
final msg = item as TextMessageItem;
|
|||
|
|
return ChatBubble(
|
|||
|
|
sender: msg.sender == MessageSender.user
|
|||
|
|
? MessageSender.user
|
|||
|
|
: MessageSender.ai,
|
|||
|
|
content: msg.content,
|
|||
|
|
timestamp: msg.timestamp,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
case ChatItemType.toolCall:
|
|||
|
|
final toolCall = item as ToolCallItem;
|
|||
|
|
return _buildToolCallItem(toolCall);
|
|||
|
|
|
|||
|
|
case ChatItemType.toolResult:
|
|||
|
|
final result = item as ToolResultItem;
|
|||
|
|
return ChatBubble(
|
|||
|
|
sender: MessageSender.ai,
|
|||
|
|
content: '',
|
|||
|
|
timestamp: result.timestamp,
|
|||
|
|
extraContent: UiSchemaRenderer.render(result.uiCard),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildToolCallItem(ToolCallItem item) {
|
|||
|
|
IconData icon;
|
|||
|
|
String text;
|
|||
|
|
Color color;
|
|||
|
|
|
|||
|
|
switch (item.status) {
|
|||
|
|
case ToolCallStatus.pending:
|
|||
|
|
icon = Icons.hourglass_empty;
|
|||
|
|
text = '正在创建日程...';
|
|||
|
|
color = AppColors.slate500;
|
|||
|
|
break;
|
|||
|
|
case ToolCallStatus.executing:
|
|||
|
|
icon = Icons.sync;
|
|||
|
|
text = '正在执行...';
|
|||
|
|
color = AppColors.blue500;
|
|||
|
|
break;
|
|||
|
|
case ToolCallStatus.completed:
|
|||
|
|
icon = Icons.check_circle;
|
|||
|
|
text = '已完成';
|
|||
|
|
color = AppColors.green500;
|
|||
|
|
break;
|
|||
|
|
case ToolCallStatus.error:
|
|||
|
|
icon = Icons.error;
|
|||
|
|
text = item.errorMessage ?? '执行失败';
|
|||
|
|
color = AppColors.red500;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Padding(
|
|||
|
|
padding: const EdgeInsets.symmetric(
|
|||
|
|
horizontal: AppSpacing.xl,
|
|||
|
|
vertical: AppSpacing.sm,
|
|||
|
|
),
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
Icon(icon, size: 16, color: color),
|
|||
|
|
const SizedBox(width: AppSpacing.sm),
|
|||
|
|
Text(text, style: TextStyle(color: color)),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新发送消息方法
|
|||
|
|
Future<void> _sendMessage(BuildContext context) async {
|
|||
|
|
final content = _messageController.text.trim();
|
|||
|
|
if (content.isEmpty) return;
|
|||
|
|
|
|||
|
|
_messageController.clear();
|
|||
|
|
context.read<ChatBloc>().sendMessage(content);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/home/ui/screens/home_screen.dart
|
|||
|
|
git commit -m "feat(chat): integrate ChatBloc into HomeScreen"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 阶段 5: 持久化
|
|||
|
|
|
|||
|
|
### Task 10: 创建 ChatHistoryRepository
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/lib/features/chat/data/repositories/chat_history_repository.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 ChatHistoryRepository**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'dart:convert';
|
|||
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|||
|
|
|
|||
|
|
class ChatHistoryRepository {
|
|||
|
|
static const String _messagesKey = 'chat_messages_';
|
|||
|
|
static const String _lastRunIdKey = 'chat_last_run_id_';
|
|||
|
|
static const String _calendarEventsKey = 'calendar_events';
|
|||
|
|
|
|||
|
|
final String threadId;
|
|||
|
|
|
|||
|
|
ChatHistoryRepository({this.threadId = 'default'});
|
|||
|
|
|
|||
|
|
String get _msgKey => '$_messagesKey$threadId';
|
|||
|
|
String get _runIdKey => '$_lastRunIdKey$threadId';
|
|||
|
|
|
|||
|
|
Future<void> saveMessages(List<Map<String, dynamic>> messages) async {
|
|||
|
|
final prefs = await SharedPreferences.getInstance();
|
|||
|
|
await prefs.setString(_msgKey, jsonEncode(messages));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<List<Map<String, dynamic>>?> loadMessages() async {
|
|||
|
|
final prefs = await SharedPreferences.getInstance();
|
|||
|
|
final data = prefs.getString(_msgKey);
|
|||
|
|
if (data == null) return null;
|
|||
|
|
|
|||
|
|
final list = jsonDecode(data) as List;
|
|||
|
|
return list.cast<Map<String, dynamic>>();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> saveLastRunId(String runId) async {
|
|||
|
|
final prefs = await SharedPreferences.getInstance();
|
|||
|
|
await prefs.setString(_runIdKey, runId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<String?> loadLastRunId() async {
|
|||
|
|
final prefs = await SharedPreferences.getInstance();
|
|||
|
|
return prefs.getString(_runIdKey);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> clear() async {
|
|||
|
|
final prefs = await SharedPreferences.getInstance();
|
|||
|
|
await prefs.remove(_msgKey);
|
|||
|
|
await prefs.remove(_runIdKey);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Calendar events 独立存储
|
|||
|
|
Future<void> saveCalendarEvent(Map<String, dynamic> event) async {
|
|||
|
|
final prefs = await SharedPreferences.getInstance();
|
|||
|
|
final eventsJson = prefs.getString(_calendarEventsKey);
|
|||
|
|
final events = eventsJson != null
|
|||
|
|
? jsonDecode(eventsJson) as Map<String, dynamic>
|
|||
|
|
: <String, dynamic>{};
|
|||
|
|
|
|||
|
|
events[event['id']] = event;
|
|||
|
|
await prefs.setString(_calendarEventsKey, jsonEncode(events));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<List<Map<String, dynamic>>> loadCalendarEvents() async {
|
|||
|
|
final prefs = await SharedPreferences.getInstance();
|
|||
|
|
final eventsJson = prefs.getString(_calendarEventsKey);
|
|||
|
|
if (eventsJson == null) return [];
|
|||
|
|
|
|||
|
|
final events = jsonDecode(eventsJson) as Map<String, dynamic>;
|
|||
|
|
return events.values.cast<Map<String, dynamic>>().toList();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/lib/features/chat/data/repositories/chat_history_repository.dart
|
|||
|
|
git commit -m "feat(chat): add ChatHistoryRepository with shared_preferences"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 测试验证
|
|||
|
|
|
|||
|
|
### Task 11: 编写单元测试
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/test/features/chat/ai_decision_engine_test.dart`
|
|||
|
|
- Create: `apps/test/features/chat/tool_registry_test.dart`
|
|||
|
|
- Create: `apps/test/features/chat/ag_ui_event_test.dart`
|
|||
|
|
|
|||
|
|
**Step 1: 写入 AiDecisionEngine 测试**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter_test/flutter_test.dart';
|
|||
|
|
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
|
|||
|
|
|
|||
|
|
void main() {
|
|||
|
|
group('AiDecisionEngine', () {
|
|||
|
|
late AiDecisionEngine engine;
|
|||
|
|
|
|||
|
|
setUp(() {
|
|||
|
|
engine = AiDecisionEngine();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group('matchIntent', () {
|
|||
|
|
test('should match searchEvent intent for "今天有什么日程"', () {
|
|||
|
|
final intent = engine.matchIntent('今天有什么日程');
|
|||
|
|
expect(intent, Intent.searchEvent);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should match createEvent intent for "提醒我明天开会"', () {
|
|||
|
|
final intent = engine.matchIntent('提醒我明天开会');
|
|||
|
|
expect(intent, Intent.createEvent);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should match createEvent intent for "明天10点有约"', () {
|
|||
|
|
final intent = engine.matchIntent('明天10点有约');
|
|||
|
|
expect(intent, Intent.createEvent);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should return unknown for random text', () {
|
|||
|
|
final intent = engine.matchIntent('你好');
|
|||
|
|
expect(intent, Intent.unknown);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group('shouldTriggerToolCall', () {
|
|||
|
|
test('should not trigger tool call for random text', () {
|
|||
|
|
final shouldTrigger = engine.shouldTriggerToolCall('你好');
|
|||
|
|
expect(shouldTrigger, false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should not trigger tool call for search intent', () {
|
|||
|
|
final shouldTrigger = engine.shouldTriggerToolCall('今天有什么日程');
|
|||
|
|
expect(shouldTrigger, false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should trigger tool call for event creation text', () {
|
|||
|
|
final shouldTrigger = engine.shouldTriggerToolCall('提醒我明天开会');
|
|||
|
|
expect(shouldTrigger, true);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group('tryExtractEventArgs', () {
|
|||
|
|
test('should extract event args from text with time', () {
|
|||
|
|
final args = engine.tryExtractEventArgs('提醒我明天10点开会');
|
|||
|
|
expect(args, isNotNull);
|
|||
|
|
expect(args!['title'], contains('开会'));
|
|||
|
|
expect(args['startAt'], isNotNull);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should return null for non-event text', () {
|
|||
|
|
final args = engine.tryExtractEventArgs('你好');
|
|||
|
|
expect(args, isNull);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should return null for search intent text', () {
|
|||
|
|
final args = engine.tryExtractEventArgs('今天有什么日程');
|
|||
|
|
expect(args, isNull);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group('tryForceTrigger', () {
|
|||
|
|
test('should parse force trigger format', () {
|
|||
|
|
final result = engine.tryForceTrigger('#tool:create_calendar_event {"title": "test"}');
|
|||
|
|
expect(result, isNotNull);
|
|||
|
|
expect(result!.toolName, 'create_calendar_event');
|
|||
|
|
expect(result.args['title'], 'test');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should return null for normal text', () {
|
|||
|
|
final result = engine.tryForceTrigger('提醒我开会');
|
|||
|
|
expect(result, isNull);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: 写入 ToolRegistry 测试**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter_test/flutter_test.dart';
|
|||
|
|
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
|
|||
|
|
|
|||
|
|
void main() {
|
|||
|
|
group('ToolRegistry', () {
|
|||
|
|
setUp(() {
|
|||
|
|
ToolRegistry.initialize();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should have create_calendar_event tool registered', () {
|
|||
|
|
final tool = ToolRegistry.getTool('create_calendar_event');
|
|||
|
|
expect(tool, isNotNull);
|
|||
|
|
expect(tool!.name, 'create_calendar_event');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should validate required args', () {
|
|||
|
|
final result = ToolRegistry.validateArgs('create_calendar_event', {});
|
|||
|
|
expect(result.ok, false);
|
|||
|
|
expect(result.error, contains('title'));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should pass validation with required args', () {
|
|||
|
|
final result = ToolRegistry.validateArgs('create_calendar_event', {
|
|||
|
|
'title': 'Test Event',
|
|||
|
|
'startAt': '2026-03-01T10:00:00Z',
|
|||
|
|
});
|
|||
|
|
expect(result.ok, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should execute tool and return result', () async {
|
|||
|
|
final result = await ToolRegistry.execute('create_calendar_event', {
|
|||
|
|
'title': 'Test Event',
|
|||
|
|
'startAt': '2026-03-01T10:00:00Z',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result['ok'], true);
|
|||
|
|
expect(result['eventId'], isNotNull);
|
|||
|
|
expect(result['title'], 'Test Event');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should throw for unknown tool', () async {
|
|||
|
|
expect(
|
|||
|
|
() => ToolRegistry.execute('unknown_tool', {}),
|
|||
|
|
throwsA(isA<ToolNotFoundException>()),
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 3: 写入 AgUiEvent 测试**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter_test/flutter_test.dart';
|
|||
|
|
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
|||
|
|
|
|||
|
|
void main() {
|
|||
|
|
group('AgUiEventTypeMapper', () {
|
|||
|
|
test('should map wire type to enum correctly', () {
|
|||
|
|
expect(
|
|||
|
|
AgUiEventTypeMapper.fromWire('TEXT_MESSAGE_START'),
|
|||
|
|
AgUiEventType.textMessageStart,
|
|||
|
|
);
|
|||
|
|
expect(
|
|||
|
|
AgUiEventTypeMapper.fromWire('TOOL_CALL_START'),
|
|||
|
|
AgUiEventType.toolCallStart,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should return unknown for unknown wire type', () {
|
|||
|
|
expect(
|
|||
|
|
AgUiEventTypeMapper.fromWire('UNKNOWN_TYPE'),
|
|||
|
|
AgUiEventType.unknown,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should map enum to wire type correctly', () {
|
|||
|
|
expect(
|
|||
|
|
AgUiEventTypeMapper.toWire(AgUiEventType.textMessageStart),
|
|||
|
|
'TEXT_MESSAGE_START',
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group('AgUiEvent.fromJson', () {
|
|||
|
|
test('should parse TextMessageStartEvent', () {
|
|||
|
|
final json = {
|
|||
|
|
'type': 'TEXT_MESSAGE_START',
|
|||
|
|
'messageId': 'msg_1',
|
|||
|
|
'role': 'assistant',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
final event = AgUiEvent.fromJson(json);
|
|||
|
|
|
|||
|
|
expect(event, isA<TextMessageStartEvent>());
|
|||
|
|
final e = event as TextMessageStartEvent;
|
|||
|
|
expect(e.messageId, 'msg_1');
|
|||
|
|
expect(e.role, 'assistant');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should parse ToolCallResultEvent with ui', () {
|
|||
|
|
final json = {
|
|||
|
|
'type': 'TOOL_CALL_RESULT',
|
|||
|
|
'messageId': 'msg_1',
|
|||
|
|
'toolCallId': 'call_1',
|
|||
|
|
'result': {'ok': true},
|
|||
|
|
'ui': {
|
|||
|
|
'type': 'calendar_card.v1',
|
|||
|
|
'data': {'id': 'evt_1'},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
final event = AgUiEvent.fromJson(json);
|
|||
|
|
|
|||
|
|
expect(event, isA<ToolCallResultEvent>());
|
|||
|
|
final e = event as ToolCallResultEvent;
|
|||
|
|
expect(e.toolCallId, 'call_1');
|
|||
|
|
expect(e.ui, isNotNull);
|
|||
|
|
expect(e.ui!.cardType, 'calendar_card.v1');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should return UnknownAgUiEvent for unknown type', () {
|
|||
|
|
final json = {
|
|||
|
|
'type': 'FUTURE_EVENT_TYPE',
|
|||
|
|
'someField': 'value',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
final event = AgUiEvent.fromJson(json);
|
|||
|
|
|
|||
|
|
expect(event, isA<UnknownAgUiEvent>());
|
|||
|
|
expect(event.type, AgUiEventType.unknown);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 4: 写入 ChatBloc 测试**
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/test/features/chat/chat_bloc_test.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter_test/flutter_test.dart';
|
|||
|
|
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
|||
|
|
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
|||
|
|
|
|||
|
|
void main() {
|
|||
|
|
group('ChatBloc', () {
|
|||
|
|
late ChatBloc bloc;
|
|||
|
|
late List<AgUiEvent> capturedEvents;
|
|||
|
|
|
|||
|
|
setUp(() {
|
|||
|
|
capturedEvents = [];
|
|||
|
|
bloc = ChatBloc(); // 使用默认 mock service
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
tearDown(() {
|
|||
|
|
bloc.close();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('initial state is empty', () {
|
|||
|
|
expect(bloc.state.items, isEmpty);
|
|||
|
|
expect(bloc.state.isLoading, false);
|
|||
|
|
expect(bloc.state.error, isNull);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group('sendMessage', () {
|
|||
|
|
test('should add user message to state', () async {
|
|||
|
|
await bloc.sendMessage('你好');
|
|||
|
|
|
|||
|
|
expect(bloc.state.items.length, greaterThan(0));
|
|||
|
|
final userMsg = bloc.state.items.first;
|
|||
|
|
expect(userMsg, isA<TextMessageItem>());
|
|||
|
|
expect((userMsg as TextMessageItem).sender, MessageSender.user);
|
|||
|
|
expect(userMsg.content, '你好');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should set isLoading to true during send', () async {
|
|||
|
|
final states = <ChatState>[];
|
|||
|
|
bloc.stream.listen(states.add);
|
|||
|
|
|
|||
|
|
await bloc.sendMessage('你好');
|
|||
|
|
|
|||
|
|
// 至少有一个状态是 loading
|
|||
|
|
expect(states.any((s) => s.isLoading), true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should trigger tool call for event creation text', () async {
|
|||
|
|
await bloc.sendMessage('提醒我明天10点开会');
|
|||
|
|
|
|||
|
|
// 应该有 tool result item
|
|||
|
|
await Future.delayed(const Duration(seconds: 2));
|
|||
|
|
|
|||
|
|
final hasToolResult = bloc.state.items.any(
|
|||
|
|
(item) => item.type == ChatItemType.toolResult,
|
|||
|
|
);
|
|||
|
|
expect(hasToolResult, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should handle tool call error gracefully', () async {
|
|||
|
|
// 使用强制触发一个会失败的工具
|
|||
|
|
await bloc.sendMessage('#tool:unknown_tool {}');
|
|||
|
|
|
|||
|
|
await Future.delayed(const Duration(seconds: 1));
|
|||
|
|
|
|||
|
|
// 应该有错误状态或错误消息
|
|||
|
|
expect(
|
|||
|
|
bloc.state.error != null ||
|
|||
|
|
bloc.state.items.any((i) =>
|
|||
|
|
i.type == ChatItemType.toolCall &&
|
|||
|
|
(i as ToolCallItem).status == ToolCallStatus.error
|
|||
|
|
),
|
|||
|
|
true,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group('clearError', () {
|
|||
|
|
test('should clear error state', () async {
|
|||
|
|
await bloc.sendMessage('#tool:unknown_tool {}');
|
|||
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|||
|
|
|
|||
|
|
if (bloc.state.error != null) {
|
|||
|
|
bloc.clearError();
|
|||
|
|
expect(bloc.state.error, isNull);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 5: 写入 AgUiService 测试**
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/test/features/chat/ag_ui_service_test.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter_test/flutter_test.dart';
|
|||
|
|
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
|||
|
|
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
|||
|
|
|
|||
|
|
void main() {
|
|||
|
|
group('AgUiService', () {
|
|||
|
|
late AgUiService service;
|
|||
|
|
late List<AgUiEvent> capturedEvents;
|
|||
|
|
|
|||
|
|
setUp(() {
|
|||
|
|
capturedEvents = [];
|
|||
|
|
service = AgUiService(onEvent: capturedEvents.add);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group('sendMessage', () {
|
|||
|
|
test('should emit RunStarted event first', () async {
|
|||
|
|
await service.sendMessage('你好');
|
|||
|
|
|
|||
|
|
expect(capturedEvents.first, isA<RunStartedEvent>());
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should emit RunFinished event last', () async {
|
|||
|
|
await service.sendMessage('你好');
|
|||
|
|
|
|||
|
|
expect(capturedEvents.last, isA<RunFinishedEvent>());
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should emit text message events for normal text', () async {
|
|||
|
|
await service.sendMessage('你好');
|
|||
|
|
|
|||
|
|
final hasStart = capturedEvents.any((e) => e is TextMessageStartEvent);
|
|||
|
|
final hasContent = capturedEvents.any((e) => e is TextMessageContentEvent);
|
|||
|
|
final hasEnd = capturedEvents.any((e) => e is TextMessageEndEvent);
|
|||
|
|
|
|||
|
|
expect(hasStart, true);
|
|||
|
|
expect(hasContent, true);
|
|||
|
|
expect(hasEnd, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should emit tool call events for event creation text', () async {
|
|||
|
|
await service.sendMessage('提醒我明天开会');
|
|||
|
|
|
|||
|
|
final hasToolStart = capturedEvents.any((e) => e is ToolCallStartEvent);
|
|||
|
|
final hasToolResult = capturedEvents.any((e) => e is ToolCallResultEvent);
|
|||
|
|
|
|||
|
|
expect(hasToolStart, true);
|
|||
|
|
expect(hasToolResult, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should parse force trigger format', () async {
|
|||
|
|
await service.sendMessage('#tool:create_calendar_event {"title":"Test"}');
|
|||
|
|
|
|||
|
|
final toolStart = capturedEvents.whereType<ToolCallStartEvent>().firstOrNull;
|
|||
|
|
expect(toolStart, isNotNull);
|
|||
|
|
expect(toolStart!.toolCallName, 'create_calendar_event');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('should emit ToolCallErrorEvent for validation failure', () async {
|
|||
|
|
// 缺少必填字段
|
|||
|
|
await service.sendMessage('#tool:create_calendar_event {}');
|
|||
|
|
|
|||
|
|
final hasError = capturedEvents.any((e) => e is ToolCallErrorEvent);
|
|||
|
|
expect(hasError, true);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 6: 写入 UiSchemaRenderer 测试**
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/test/features/chat/ui_schema_renderer_test.dart`
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:flutter_test/flutter_test.dart';
|
|||
|
|
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
|
|||
|
|
import 'package:social_app/features/chat/data/models/tool_result.dart';
|
|||
|
|
|
|||
|
|
void main() {
|
|||
|
|
group('UiSchemaRenderer', () {
|
|||
|
|
group('render', () {
|
|||
|
|
testWidgets('should render calendar_card.v1', (tester) async {
|
|||
|
|
final card = UiCard(
|
|||
|
|
cardType: 'calendar_card.v1',
|
|||
|
|
data: {
|
|||
|
|
'id': 'evt_1',
|
|||
|
|
'title': 'Test Event',
|
|||
|
|
'startAt': '2026-03-01T10:00:00Z',
|
|||
|
|
'color': '#4F46E5',
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await tester.pumpWidget(
|
|||
|
|
MaterialApp(
|
|||
|
|
home: Scaffold(
|
|||
|
|
body: UiSchemaRenderer.render(card),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(find.text('Test Event'), findsOneWidget);
|
|||
|
|
expect(find.text('AI生成'), findsOneWidget);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
testWidgets('should render error_card.v1', (tester) async {
|
|||
|
|
final card = UiCard(
|
|||
|
|
cardType: 'error_card.v1',
|
|||
|
|
data: {'message': 'Something went wrong'},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await tester.pumpWidget(
|
|||
|
|
MaterialApp(
|
|||
|
|
home: Scaffold(
|
|||
|
|
body: UiSchemaRenderer.render(card),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(find.text('Something went wrong'), findsOneWidget);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
testWidgets('should render unknown card with fallback', (tester) async {
|
|||
|
|
final card = UiCard(
|
|||
|
|
cardType: 'future_card_type.v99',
|
|||
|
|
data: {'foo': 'bar'},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await tester.pumpWidget(
|
|||
|
|
MaterialApp(
|
|||
|
|
home: Scaffold(
|
|||
|
|
body: UiSchemaRenderer.render(card),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(find.textContaining('Unknown card type'), findsOneWidget);
|
|||
|
|
expect(find.textContaining('foo'), findsOneWidget);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
testWidgets('should render calendar card with actions', (tester) async {
|
|||
|
|
final card = UiCard(
|
|||
|
|
cardType: 'calendar_card.v1',
|
|||
|
|
data: {
|
|||
|
|
'id': 'evt_1',
|
|||
|
|
'title': 'Meeting',
|
|||
|
|
'startAt': '2026-03-01T10:00:00Z',
|
|||
|
|
},
|
|||
|
|
actions: [
|
|||
|
|
CardAction(type: 'open', label: '打开'),
|
|||
|
|
CardAction(type: 'edit', label: '编辑'),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await tester.pumpWidget(
|
|||
|
|
MaterialApp(
|
|||
|
|
home: Scaffold(
|
|||
|
|
body: UiSchemaRenderer.render(card),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(find.text('打开'), findsOneWidget);
|
|||
|
|
expect(find.text('编辑'), findsOneWidget);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 7: 运行所有测试**
|
|||
|
|
|
|||
|
|
Run: `cd apps && flutter test test/features/chat/`
|
|||
|
|
|
|||
|
|
Expected: All tests PASS
|
|||
|
|
|
|||
|
|
**Step 8: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/test/features/chat/
|
|||
|
|
git commit -m "test(chat): add comprehensive unit tests for bloc, service and renderer"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 完成验证
|
|||
|
|
|
|||
|
|
**验证命令:**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# 运行所有测试
|
|||
|
|
cd apps && flutter test
|
|||
|
|
|
|||
|
|
# Lint
|
|||
|
|
cd apps && flutter analyze
|
|||
|
|
|
|||
|
|
# 构建
|
|||
|
|
cd apps && flutter build apk --debug
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 文件清单
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
apps/lib/features/chat/
|
|||
|
|
├── data/
|
|||
|
|
│ ├── models/
|
|||
|
|
│ │ ├── ag_ui_event.dart
|
|||
|
|
│ │ ├── tool_result.dart
|
|||
|
|
│ │ └── chat_list_item.dart
|
|||
|
|
│ ├── tools/
|
|||
|
|
│ │ └── tool_registry.dart
|
|||
|
|
│ ├── services/
|
|||
|
|
│ │ └── ag_ui_service.dart
|
|||
|
|
│ ├── ai/
|
|||
|
|
│ │ └── ai_decision_engine.dart
|
|||
|
|
│ └── repositories/
|
|||
|
|
│ └── chat_history_repository.dart
|
|||
|
|
└── presentation/
|
|||
|
|
├── bloc/
|
|||
|
|
│ └── chat_bloc.dart
|
|||
|
|
└── ui/
|
|||
|
|
└── widgets/
|
|||
|
|
└── ui_schema_renderer.dart
|
|||
|
|
|
|||
|
|
apps/test/features/chat/
|
|||
|
|
├── ai_decision_engine_test.dart
|
|||
|
|
├── tool_registry_test.dart
|
|||
|
|
├── ag_ui_event_test.dart
|
|||
|
|
├── chat_bloc_test.dart
|
|||
|
|
├── ag_ui_service_test.dart
|
|||
|
|
└── ui_schema_renderer_test.dart
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**Plan v1.1 complete.** 可选择执行方式:
|
|||
|
|
|
|||
|
|
1. **Subagent-Driven(本会话)** - 每个任务由 subagent 执行,快速迭代
|
|||
|
|
2. **Parallel Session(单独会话)** - 在新会话中使用 executing-plans,分批执行
|
|||
|
|
|
|||
|
|
需要我开始复审这个修订版计划吗?
|