refactor(chat): 重构聊天模块并集成历史消息加载功能
- 删除冗余的 chat_history_repository 和 home_mock_data - 简化 ag_ui_event fromJson 使用工厂映射表 - 提取 ChatBloc 事件处理方法,添加 loadHistory/loadMoreHistory - HomeScreen 集成 ChatBloc 实现历史消息加载和下拉刷新 - 更新 AGENTS.md 文档约束
This commit is contained in:
+51
-155
@@ -1,174 +1,70 @@
|
||||
# Flutter Mobile Development Rules
|
||||
# Flutter Mobile Development Constraints
|
||||
|
||||
This document defines Flutter mobile development constraints.
|
||||
This document defines **hard constraints** for Flutter mobile development. Treat all items as **non-negotiable** unless explicitly overridden.
|
||||
|
||||
## Design System
|
||||
## 1) Design Tokens (MUST)
|
||||
|
||||
### Design Tokens
|
||||
- **MUST** use design tokens from `apps/lib/core/theme/design_tokens.dart`:
|
||||
- Colors: `AppColors.*`
|
||||
- Spacing: `AppSpacing.*`
|
||||
- Radius: `AppRadius.*`
|
||||
- **MUST NOT** hardcode any visual values, including (but not limited to): colors, font sizes, spacing, padding/margins, widths/heights, radii, shadows, opacity, or “magic numbers”.
|
||||
- Examples that are **NOT allowed**: `Color(0xFF...)`, `SizedBox(height: 12)`, `EdgeInsets.all(16)`, `Radius.circular(8)`.
|
||||
|
||||
All UI styling must use design tokens from `apps/lib/core/theme/design_tokens.dart`:
|
||||
## 2) Component Reuse (MUST)
|
||||
|
||||
| Type | Usage |
|
||||
|------|-------|
|
||||
| Colors | `AppColors.primary`, `AppColors.slate500`, `AppColors.background` |
|
||||
| Spacing | `AppSpacing.xs`, `AppSpacing.sm`, `AppSpacing.md` |
|
||||
| Radius | `AppRadius.sm`, `AppRadius.md`, `AppRadius.lg` |
|
||||
- **MUST** prefer existing components and established page patterns over creating new UI components.
|
||||
- **MUST** use:
|
||||
- Buttons: `AppButton` from `apps/lib/shared/widgets/app_button.dart`
|
||||
- **MUST NOT** introduce parallel UI systems (custom buttons, custom loading systems, custom input wrappers) unless explicitly required and approved.
|
||||
|
||||
**NEVER hardcode colors, sizes, or spacing values.**
|
||||
## 3) Layout Mapping & Alignment (MUST)
|
||||
|
||||
### Reuse Existing Components
|
||||
- **MUST** explicitly set `crossAxisAlignment` for every `Row` / `Column` (do not rely on defaults).
|
||||
- **MUST** preserve layout semantics from root to leaf:
|
||||
- alignment/justification intent must be explicitly represented in Flutter widgets.
|
||||
- **MUST NOT** skip necessary container layers if doing so loses layout meaning or makes mapping non-traceable.
|
||||
|
||||
Use pre-built components instead of creating custom ones:
|
||||
- Buttons: Use `AppButton` widget from `apps/lib/shared/widgets/app_button.dart`
|
||||
- Input fields: Use standard Flutter `TextField` with `InputDecoration`
|
||||
- Loading states: Use built-in loading indicators
|
||||
## 4) Centering & Visual Balance (MUST)
|
||||
|
||||
## New Page Design Workflow
|
||||
- **MUST** evaluate centering within `SafeArea` usable bounds (not full-screen bounds).
|
||||
- **MUST NOT** rely on `Spacer` / proportional flex as the only centering mechanism for critical content.
|
||||
- If persistent header/footer regions exist, **MUST** center primary content within the remaining usable region.
|
||||
- **MUST** prioritize *visual centering* over purely geometric centering when they differ.
|
||||
|
||||
1. **Analyze existing pages**: Study login, register, home screens for:
|
||||
- Layout structure (centered form, padding, spacing)
|
||||
- Typography hierarchy (title 28px bold, label 13px, hint 14px)
|
||||
- Component usage (AppButton, TextField style)
|
||||
- Color and spacing tokens
|
||||
## 5) Quality Gate for Important Screens (MUST)
|
||||
|
||||
2. **Use frontend-design skill for mockups**:
|
||||
```
|
||||
Use the `frontend-design` skill to create HTML/CSS mockups for review
|
||||
Match colors to `apps/lib/core/theme/design_tokens.dart`
|
||||
Match spacing to `AppSpacing` values
|
||||
Match radius to `AppRadius` values
|
||||
```
|
||||
For important screens:
|
||||
|
||||
3. **Verify design tokens**:
|
||||
- All colors from `AppColors`
|
||||
- All spacing from `AppSpacing`
|
||||
- All radius from `AppRadius`
|
||||
- NO hardcoded values
|
||||
- **MUST** add widget tests to reduce layout regression risk:
|
||||
- Verify primary content stays centered relative to the usable viewport.
|
||||
- Include at least one constrained scenario (e.g., small height **or** large text scale).
|
||||
|
||||
4. **Code review checklist**:
|
||||
- [ ] All colors/spacing/radius use design tokens
|
||||
- [ ] Reuses existing components (AppButton)
|
||||
- [ ] Consistent with existing page patterns
|
||||
- [ ] No magic numbers
|
||||
## 6) UI Feedback System (MUST)
|
||||
|
||||
## Layout Mapping Rules
|
||||
- All user-facing feedback **MUST** use the Toast system.
|
||||
- Transient notifications: `Toast.show(...)`
|
||||
- Persistent inline form errors: `AppBanner`
|
||||
- **MUST NOT** create custom SnackBar/Dialog/Banner feedback components.
|
||||
- **MUST NOT** use raw `ScaffoldMessenger` for feedback messaging.
|
||||
|
||||
Map design layout properties to Flutter explicitly:
|
||||
## 7) Agent Chat (AG-UI Protocol) (MUST)
|
||||
|
||||
1. **Always set `crossAxisAlignment` on `Row`/`Column`**:
|
||||
- `alignItems: center` -> `CrossAxisAlignment.center`
|
||||
- `alignItems: start` -> `CrossAxisAlignment.start`
|
||||
- `alignItems: stretch` -> `CrossAxisAlignment.stretch`
|
||||
2. **Map full container chain**: From root to leaf, ensure each `alignItems` and `justifyContent` has a Flutter equivalent.
|
||||
3. **Analyze before coding**: Verify each container's alignment settings.
|
||||
Agent chat functionality **MUST** follow the AG-UI protocol: `docs/knowledges/ag-ui-llms-full.txt`.
|
||||
|
||||
## Centering and Visual Balance
|
||||
- **MUST** use Server-Sent Events (SSE) for streaming.
|
||||
- **MUST** emit required lifecycle events:
|
||||
- `RUN_STARTED` is required for every run
|
||||
- End with exactly one of: `RUN_FINISHED` or `RUN_ERROR`
|
||||
- **MUST** follow standard text streaming flow:
|
||||
- `TEXT_MESSAGE_START` → `TEXT_MESSAGE_CONTENT` (delta) → `TEXT_MESSAGE_END`
|
||||
- **MUST** support the standard AG-UI event type set as defined in the spec.
|
||||
- **MUST NOT** return non-streaming responses for agent chat.
|
||||
- **MUST NOT** omit required lifecycle events.
|
||||
- **MUST NOT** use non-AG-UI event formats (except where the spec explicitly allows).
|
||||
|
||||
1. Centering must be evaluated inside **`SafeArea`** bounds, not full-screen bounds.
|
||||
2. Avoid relying on proportional `Spacer` values as the only centering mechanism for critical content.
|
||||
3. For layouts with persistent top/bottom regions (e.g., headers or footers), center the primary content in the remaining available region.
|
||||
4. Distinguish geometric centering from visual centering; validate final visual balance with screenshot review.
|
||||
## 8) Debugging Behavior (MUST)
|
||||
|
||||
## Quality Gate
|
||||
|
||||
For important screens, add widget tests that reduce layout-regression risk:
|
||||
|
||||
1. Verify primary content remains centered relative to the usable viewport.
|
||||
2. Add at least one constrained viewport scenario (small height or large text scale).
|
||||
|
||||
## Prohibitions
|
||||
|
||||
- DO NOT use colors not defined in design tokens
|
||||
- DO NOT skip design container layers
|
||||
- DO NOT start implementation before retrieving design variables
|
||||
- DO NOT hardcode colors; use design variables
|
||||
|
||||
## UI Feedback System
|
||||
|
||||
**MUST use the Toast system for all user feedback messages.**
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Use Case | Example |
|
||||
|-----------|----------|---------|
|
||||
| `Toast.show()` | Global temporary notifications | Success/error feedback after action |
|
||||
| `AppBanner` | Inline form validation errors | Login form error message |
|
||||
|
||||
### Toast Types
|
||||
|
||||
```dart
|
||||
enum ToastType { info, success, warning, error }
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Global Toast (auto-dismiss):**
|
||||
```dart
|
||||
Toast.show(context, '保存成功', type: ToastType.success);
|
||||
Toast.show(context, '网络错误', type: ToastType.error);
|
||||
Toast.show(context, '正在加载...', type: ToastType.info, duration: Duration(seconds: 3));
|
||||
```
|
||||
|
||||
**Inline Banner (persistent):**
|
||||
```dart
|
||||
AppBanner(message: '邮箱或密码错误', type: ToastType.error)
|
||||
AppBanner(message: '请检查输入', type: ToastType.warning)
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Use `Toast` for transient feedback that auto-dismisses
|
||||
- Use `AppBanner` for persistent inline messages (form errors)
|
||||
- DO NOT create custom SnackBar, Dialog, or Banner components
|
||||
- DO NOT use raw `ScaffoldMessenger`
|
||||
|
||||
## Agent Chat (AG-UI Protocol)
|
||||
|
||||
**Agent chat functionality MUST follow the AG-UI protocol**, reference `docs/knowledges/ag-ui-llms-full.txt`.
|
||||
|
||||
### Core Requirements
|
||||
|
||||
1. **Event-Driven Architecture**: Implement event-driven streaming responses
|
||||
2. **Event Types**: Must support the 16 standard event types:
|
||||
- **Lifecycle**: `RUN_STARTED`, `RUN_FINISHED`, `RUN_ERROR`, `STEP_STARTED`, `STEP_FINISHED`
|
||||
- **Text Message**: `TEXT_MESSAGE_START`, `TEXT_MESSAGE_CONTENT`, `TEXT_MESSAGE_END`
|
||||
- **Tool Call**: `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, `TOOL_CALL_RESULT`
|
||||
- **State Management**: `STATE_SNAPSHOT`, `STATE_DELTA`, `MESSAGES_SNAPSHOT`
|
||||
- **Special**: `RAW`, `CUSTOM`
|
||||
|
||||
3. **Transport**: Use Server-Sent Events (SSE) for streaming
|
||||
|
||||
4. **Event Flow**: Follow the standard pattern:
|
||||
- `RUN_STARTED` (required) → [optional events] → `RUN_FINISHED` or `RUN_ERROR` (required)
|
||||
- Text messages: `TEXT_MESSAGE_START` → `TEXT_MESSAGE_CONTENT` (delta) → `TEXT_MESSAGE_END`
|
||||
|
||||
5. **Frontend Integration**: Use AG-UI compatible client libraries
|
||||
|
||||
### Event Reference
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `RUN_STARTED` | Signals the start of an agent run |
|
||||
| `RUN_FINISHED` | Signals successful completion |
|
||||
| `RUN_ERROR` | Signals an error during execution |
|
||||
| `TEXT_MESSAGE_START` | Initializes a new text message with unique messageId |
|
||||
| `TEXT_MESSAGE_CONTENT` | Delivers incremental text chunks (delta) |
|
||||
| `TEXT_MESSAGE_END` | Marks message completion |
|
||||
|
||||
### Prohibitions
|
||||
|
||||
- DO NOT return non-streaming responses for agent chat
|
||||
- DO NOT skip required lifecycle events (RUN_STARTED, RUN_FINISHED/RUN_ERROR)
|
||||
- DO NOT use custom event formats outside of AG-UI specification
|
||||
|
||||
|
||||
## App Debugging
|
||||
|
||||
**DO NOT automatically start Flutter app debugging.**
|
||||
|
||||
After completing code changes, inform the user to manually run:
|
||||
```bash
|
||||
flutter run --dart-define=MOCK_API=true -d emulator-5554
|
||||
```
|
||||
|
||||
Let the user control when to launch the app for testing.
|
||||
- **MUST NOT** automatically start Flutter app debugging or running.
|
||||
- After code changes, **MUST** instruct the user to run manually (user-controlled):
|
||||
- `flutter run --dart-define=MOCK_API=true -d emulator-5554`
|
||||
|
||||
@@ -34,6 +34,7 @@ enum AgUiEventType {
|
||||
unknown,
|
||||
}
|
||||
|
||||
// wire 类型到枚举的映射
|
||||
const _wireToTypeMap = {
|
||||
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
|
||||
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
|
||||
@@ -49,6 +50,7 @@ const _wireToTypeMap = {
|
||||
AgUiEventTypeWire.messagesSnapshot: AgUiEventType.messagesSnapshot,
|
||||
};
|
||||
|
||||
// 枚举到 wire 类型的映射
|
||||
const _typeToWireMap = {
|
||||
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
|
||||
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
|
||||
@@ -70,6 +72,23 @@ AgUiEventType agUiEventTypeFromWire(String wire) =>
|
||||
|
||||
String agUiEventTypeToWire(AgUiEventType type) => _typeToWireMap[type] ?? '';
|
||||
|
||||
// 类型到工厂函数的映射,用于简化 fromJson
|
||||
final _typeToFactory = {
|
||||
AgUiEventType.runStarted: RunStartedEvent.fromJson,
|
||||
AgUiEventType.runFinished: RunFinishedEvent.fromJson,
|
||||
AgUiEventType.runError: RunErrorEvent.fromJson,
|
||||
AgUiEventType.textMessageStart: TextMessageStartEvent.fromJson,
|
||||
AgUiEventType.textMessageContent: TextMessageContentEvent.fromJson,
|
||||
AgUiEventType.textMessageEnd: TextMessageEndEvent.fromJson,
|
||||
AgUiEventType.toolCallStart: ToolCallStartEvent.fromJson,
|
||||
AgUiEventType.toolCallArgs: ToolCallArgsEvent.fromJson,
|
||||
AgUiEventType.toolCallEnd: ToolCallEndEvent.fromJson,
|
||||
AgUiEventType.toolCallResult: ToolCallResultEvent.fromJson,
|
||||
AgUiEventType.toolCallError: ToolCallErrorEvent.fromJson,
|
||||
AgUiEventType.messagesSnapshot: MessagesSnapshotEvent.fromJson,
|
||||
AgUiEventType.unknown: UnknownAgUiEvent.fromJson,
|
||||
};
|
||||
|
||||
@JsonSerializable()
|
||||
class AgUiEvent {
|
||||
final AgUiEventType type;
|
||||
@@ -79,35 +98,7 @@ class AgUiEvent {
|
||||
factory AgUiEvent.fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String? ?? '';
|
||||
final type = agUiEventTypeFromWire(typeStr);
|
||||
|
||||
switch (type) {
|
||||
case AgUiEventType.runStarted:
|
||||
return RunStartedEvent.fromJson(json);
|
||||
case AgUiEventType.runFinished:
|
||||
return RunFinishedEvent.fromJson(json);
|
||||
case AgUiEventType.runError:
|
||||
return RunErrorEvent.fromJson(json);
|
||||
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.messagesSnapshot:
|
||||
return MessagesSnapshotEvent.fromJson(json);
|
||||
case AgUiEventType.unknown:
|
||||
return UnknownAgUiEvent.fromJson(json);
|
||||
}
|
||||
return _typeToFactory[type]?.call(json) ?? UnknownAgUiEvent.fromJson(json);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => _$AgUiEventToJson(this);
|
||||
@@ -322,6 +313,7 @@ class SnapshotMessage {
|
||||
final String? content;
|
||||
final String? toolCallId;
|
||||
final UiCard? ui;
|
||||
final DateTime? timestamp;
|
||||
|
||||
SnapshotMessage({
|
||||
required this.id,
|
||||
@@ -329,6 +321,7 @@ class SnapshotMessage {
|
||||
this.content,
|
||||
this.toolCallId,
|
||||
this.ui,
|
||||
this.timestamp,
|
||||
});
|
||||
|
||||
factory SnapshotMessage.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -25,6 +25,7 @@ const _$AgUiEventTypeEnumMap = {
|
||||
AgUiEventType.toolCallEnd: 'toolCallEnd',
|
||||
AgUiEventType.toolCallResult: 'toolCallResult',
|
||||
AgUiEventType.toolCallError: 'toolCallError',
|
||||
AgUiEventType.messagesSnapshot: 'messagesSnapshot',
|
||||
AgUiEventType.unknown: 'unknown',
|
||||
};
|
||||
|
||||
@@ -157,3 +158,39 @@ Map<String, dynamic> _$ToolCallErrorEventToJson(ToolCallErrorEvent instance) =>
|
||||
'error': instance.error,
|
||||
'code': instance.code,
|
||||
};
|
||||
|
||||
MessagesSnapshotEvent _$MessagesSnapshotEventFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => MessagesSnapshotEvent(
|
||||
messages: (json['messages'] as List<dynamic>)
|
||||
.map((e) => SnapshotMessage.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MessagesSnapshotEventToJson(
|
||||
MessagesSnapshotEvent instance,
|
||||
) => <String, dynamic>{'messages': instance.messages};
|
||||
|
||||
SnapshotMessage _$SnapshotMessageFromJson(Map<String, dynamic> json) =>
|
||||
SnapshotMessage(
|
||||
id: json['id'] as String,
|
||||
role: json['role'] as String,
|
||||
content: json['content'] as String?,
|
||||
toolCallId: json['toolCallId'] as String?,
|
||||
ui: json['ui'] == null
|
||||
? null
|
||||
: UiCard.fromJson(json['ui'] as Map<String, dynamic>),
|
||||
timestamp: json['timestamp'] == null
|
||||
? null
|
||||
: DateTime.parse(json['timestamp'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnapshotMessageToJson(SnapshotMessage instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'role': instance.role,
|
||||
'content': instance.content,
|
||||
'toolCallId': instance.toolCallId,
|
||||
'ui': instance.ui,
|
||||
'timestamp': instance.timestamp?.toIso8601String(),
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ Map<String, dynamic> _$ToolResultToJson(ToolResult instance) =>
|
||||
|
||||
UiCard _$UiCardFromJson(Map<String, dynamic> json) => UiCard(
|
||||
cardType: json['type'] as String,
|
||||
schemaVersion: json['version'] as String? ?? 'v1',
|
||||
schemaVersion: json['version'] as String? ?? _defaultSchemaVersion,
|
||||
data: json['data'] as Map<String, dynamic>,
|
||||
actions: (json['actions'] as List<dynamic>?)
|
||||
?.map((e) => CardAction.fromJson(e as Map<String, dynamic>))
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,6 @@ class AgUiService {
|
||||
Future<void> loadHistory({DateTime? beforeDate}) async {
|
||||
if (Env.isMockApi) {
|
||||
await _mockLoadHistory(beforeDate: beforeDate);
|
||||
} else {
|
||||
throw UnimplementedError('Real API not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +56,10 @@ class AgUiService {
|
||||
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
DateTime targetDate;
|
||||
// Determine target date, end early if no earlier history
|
||||
final DateTime targetDate;
|
||||
if (beforeDate != null) {
|
||||
final prevDate = _historyService.getPreviousDay(beforeDate);
|
||||
if (prevDate == null) {
|
||||
@@ -72,9 +72,8 @@ class AgUiService {
|
||||
}
|
||||
|
||||
final messages = _historyService.getHistoryForDay(targetDate);
|
||||
|
||||
onEvent(MessagesSnapshotEvent(messages: messages));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,40 +6,35 @@ class MockHistoryService {
|
||||
factory MockHistoryService() => _instance;
|
||||
MockHistoryService._internal();
|
||||
|
||||
/// Normalize DateTime to date-only (midnight)
|
||||
DateTime _toDateOnly(DateTime date) =>
|
||||
DateTime(date.year, date.month, date.day);
|
||||
|
||||
List<SnapshotMessage> getHistoryForDay(DateTime date) {
|
||||
final dayStart = DateTime(date.year, date.month, date.day);
|
||||
final dayStart = _toDateOnly(date);
|
||||
final allHistory = _generateAllHistory();
|
||||
|
||||
return allHistory.where((msg) {
|
||||
if (msg.ui != null) {
|
||||
final data = msg.ui!.data;
|
||||
final startAtStr = data['startAt'] as String?;
|
||||
if (startAtStr != null) {
|
||||
try {
|
||||
final startAt = DateTime.parse(startAtStr);
|
||||
final msgDate = DateTime(startAt.year, startAt.month, startAt.day);
|
||||
return msgDate == dayStart;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
if (msg.timestamp == null) return false;
|
||||
final msgDate = _toDateOnly(msg.timestamp!);
|
||||
return msgDate == dayStart;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
DateTime? getLatestHistoryDate() {
|
||||
final now = DateTime.now();
|
||||
return DateTime(now.year, now.month, now.day);
|
||||
final allHistory = _generateAllHistory();
|
||||
if (allHistory.isEmpty) return null;
|
||||
|
||||
return allHistory
|
||||
.where((msg) => msg.timestamp != null)
|
||||
.map((msg) => _toDateOnly(msg.timestamp!))
|
||||
.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
}
|
||||
|
||||
DateTime? getPreviousDay(DateTime currentDate) {
|
||||
final allDates = _getAllHistoryDates();
|
||||
final sortedDates = allDates.toList()..sort((a, b) => b.compareTo(a));
|
||||
|
||||
final currentDateOnly = DateTime(
|
||||
currentDate.year,
|
||||
currentDate.month,
|
||||
currentDate.day,
|
||||
);
|
||||
final currentDateOnly = _toDateOnly(currentDate);
|
||||
|
||||
for (final date in sortedDates) {
|
||||
if (date.isBefore(currentDateOnly)) {
|
||||
@@ -51,29 +46,35 @@ class MockHistoryService {
|
||||
|
||||
bool hasEarlierHistory(DateTime fromDate) {
|
||||
final allDates = _getAllHistoryDates();
|
||||
final fromDateOnly = DateTime(fromDate.year, fromDate.month, fromDate.day);
|
||||
final fromDateOnly = _toDateOnly(fromDate);
|
||||
|
||||
return allDates.any((date) => date.isBefore(fromDateOnly));
|
||||
}
|
||||
|
||||
Set<DateTime> _getAllHistoryDates() {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final today = _toDateOnly(now);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
return {today, yesterday};
|
||||
}
|
||||
|
||||
List<SnapshotMessage> _generateAllHistory() {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final today = _toDateOnly(now);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
|
||||
return [
|
||||
SnapshotMessage(id: 'hist-m1', role: 'user', content: '明天提醒我开会'),
|
||||
SnapshotMessage(
|
||||
id: 'hist-m1',
|
||||
role: 'user',
|
||||
content: '明天提醒我开会',
|
||||
timestamp: today.add(const Duration(hours: 10)),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-t1',
|
||||
role: 'tool',
|
||||
toolCallId: 'hist-tc1',
|
||||
timestamp: today.add(const Duration(hours: 10)),
|
||||
ui: UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
@@ -104,21 +105,26 @@ class MockHistoryService {
|
||||
id: 'hist-m2',
|
||||
role: 'assistant',
|
||||
content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。',
|
||||
timestamp: today.add(const Duration(hours: 10)),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-m3',
|
||||
role: 'user',
|
||||
content: '下周一之前提交项目报告',
|
||||
timestamp: yesterday.add(const Duration(hours: 14)),
|
||||
),
|
||||
SnapshotMessage(id: 'hist-m3', role: 'user', content: '下周一之前提交项目报告'),
|
||||
SnapshotMessage(
|
||||
id: 'hist-t2',
|
||||
role: 'tool',
|
||||
toolCallId: 'hist-tc2',
|
||||
timestamp: yesterday.add(const Duration(hours: 14)),
|
||||
ui: UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'hist-s2',
|
||||
title: '提交项目报告',
|
||||
description: '完成并提交Q2项目报告',
|
||||
startAt: yesterday
|
||||
.subtract(const Duration(days: 3))
|
||||
.toIso8601String(),
|
||||
startAt: yesterday.add(const Duration(days: 5)).toIso8601String(),
|
||||
endAt: null,
|
||||
timezone: 'Asia/Shanghai',
|
||||
location: null,
|
||||
@@ -138,11 +144,13 @@ class MockHistoryService {
|
||||
id: 'hist-m4',
|
||||
role: 'assistant',
|
||||
content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。',
|
||||
timestamp: yesterday.add(const Duration(hours: 14)),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-m5',
|
||||
role: 'assistant',
|
||||
content: '你好,我有什么可以帮你的?',
|
||||
timestamp: yesterday.add(const Duration(hours: 9)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -63,126 +63,218 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
switch (event.type) {
|
||||
case AgUiEventType.runStarted:
|
||||
emit(state.copyWith(isLoading: true, error: null));
|
||||
break;
|
||||
case AgUiEventType.runFinished:
|
||||
emit(state.copyWith(isLoading: false, currentMessageId: null));
|
||||
break;
|
||||
case AgUiEventType.runError:
|
||||
final errorEvent = event as RunErrorEvent;
|
||||
emit(state.copyWith(isLoading: false, error: errorEvent.message));
|
||||
break;
|
||||
case AgUiEventType.textMessageStart:
|
||||
final startEvent = event as TextMessageStartEvent;
|
||||
final newMessage = TextMessageItem(
|
||||
id: startEvent.messageId,
|
||||
content: '',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: [...state.items, newMessage],
|
||||
currentMessageId: startEvent.messageId,
|
||||
),
|
||||
);
|
||||
break;
|
||||
_handleTextMessageStart(event as TextMessageStartEvent);
|
||||
case AgUiEventType.textMessageContent:
|
||||
final contentEvent = event as TextMessageContentEvent;
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == contentEvent.messageId && item is TextMessageItem) {
|
||||
return item.copyWith(content: item.content + contentEvent.delta);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
break;
|
||||
_handleTextMessageContent(event as TextMessageContentEvent);
|
||||
case AgUiEventType.textMessageEnd:
|
||||
final endEvent = event as TextMessageEndEvent;
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.messageId && item is TextMessageItem) {
|
||||
return item.copyWith(isStreaming: false);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems, currentMessageId: null));
|
||||
break;
|
||||
_handleTextMessageEnd(event as TextMessageEndEvent);
|
||||
case AgUiEventType.toolCallStart:
|
||||
final startEvent = event as ToolCallStartEvent;
|
||||
_toolCallArgsBuffer[startEvent.toolCallId] = '';
|
||||
final newToolCall = ToolCallItem(
|
||||
id: startEvent.toolCallId,
|
||||
callId: startEvent.toolCallId,
|
||||
toolName: startEvent.toolCallName,
|
||||
args: {},
|
||||
status: ToolCallStatus.pending,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
emit(state.copyWith(items: [...state.items, newToolCall]));
|
||||
break;
|
||||
_handleToolCallStart(event as ToolCallStartEvent);
|
||||
case AgUiEventType.toolCallArgs:
|
||||
final argsEvent = event as ToolCallArgsEvent;
|
||||
_toolCallArgsBuffer[argsEvent.toolCallId] =
|
||||
(_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta;
|
||||
break;
|
||||
_handleToolCallArgs(event as ToolCallArgsEvent);
|
||||
case AgUiEventType.toolCallEnd:
|
||||
final endEvent = event as ToolCallEndEvent;
|
||||
final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? '';
|
||||
Map<String, dynamic> parsedArgs = {};
|
||||
if (argsBuffer.isNotEmpty) {
|
||||
try {
|
||||
parsedArgs = jsonDecode(argsBuffer) as Map<String, dynamic>;
|
||||
} catch (_) {}
|
||||
}
|
||||
_toolCallArgsBuffer.remove(endEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
|
||||
return item.copyWith(
|
||||
args: parsedArgs,
|
||||
status: ToolCallStatus.executing,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
break;
|
||||
_handleToolCallEnd(event as ToolCallEndEvent);
|
||||
case AgUiEventType.toolCallResult:
|
||||
final resultEvent = event as ToolCallResultEvent;
|
||||
final filteredItems = state.items.where((item) {
|
||||
if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
final resultItem = ToolResultItem(
|
||||
id: resultEvent.messageId,
|
||||
callId: resultEvent.toolCallId,
|
||||
uiCard: resultEvent.ui ?? UiCard(cardType: 'empty', data: {}),
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
emit(state.copyWith(items: [...filteredItems, resultItem]));
|
||||
break;
|
||||
_handleToolCallResult(event as ToolCallResultEvent);
|
||||
case AgUiEventType.toolCallError:
|
||||
final errorEvent = event as ToolCallErrorEvent;
|
||||
_toolCallArgsBuffer.remove(errorEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
|
||||
return item.copyWith(
|
||||
status: ToolCallStatus.error,
|
||||
errorMessage: errorEvent.error,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
break;
|
||||
_handleToolCallError(event as ToolCallErrorEvent);
|
||||
case AgUiEventType.messagesSnapshot:
|
||||
_handleMessagesSnapshot(event as MessagesSnapshotEvent);
|
||||
case AgUiEventType.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextMessageStart(TextMessageStartEvent startEvent) {
|
||||
final newMessage = TextMessageItem(
|
||||
id: startEvent.messageId,
|
||||
content: '',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: [...state.items, newMessage],
|
||||
currentMessageId: startEvent.messageId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTextMessageContent(TextMessageContentEvent contentEvent) {
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == contentEvent.messageId && item is TextMessageItem) {
|
||||
return item.copyWith(content: item.content + contentEvent.delta);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
}
|
||||
|
||||
void _handleTextMessageEnd(TextMessageEndEvent endEvent) {
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.messageId && item is TextMessageItem) {
|
||||
return item.copyWith(isStreaming: false);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems, currentMessageId: null));
|
||||
}
|
||||
|
||||
void _handleToolCallStart(ToolCallStartEvent startEvent) {
|
||||
_toolCallArgsBuffer[startEvent.toolCallId] = '';
|
||||
final newToolCall = ToolCallItem(
|
||||
id: startEvent.toolCallId,
|
||||
callId: startEvent.toolCallId,
|
||||
toolName: startEvent.toolCallName,
|
||||
args: {},
|
||||
status: ToolCallStatus.pending,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
emit(state.copyWith(items: [...state.items, newToolCall]));
|
||||
}
|
||||
|
||||
void _handleToolCallArgs(ToolCallArgsEvent argsEvent) {
|
||||
_toolCallArgsBuffer[argsEvent.toolCallId] =
|
||||
(_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta;
|
||||
}
|
||||
|
||||
void _handleToolCallEnd(ToolCallEndEvent endEvent) {
|
||||
final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? '';
|
||||
Map<String, dynamic> parsedArgs = {};
|
||||
if (argsBuffer.isNotEmpty) {
|
||||
try {
|
||||
parsedArgs = jsonDecode(argsBuffer) as Map<String, dynamic>;
|
||||
} catch (_) {}
|
||||
}
|
||||
_toolCallArgsBuffer.remove(endEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
|
||||
return item.copyWith(
|
||||
args: parsedArgs,
|
||||
status: ToolCallStatus.executing,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
}
|
||||
|
||||
void _handleToolCallResult(ToolCallResultEvent resultEvent) {
|
||||
final filteredItems = state.items.where((item) {
|
||||
if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
final resultItem = ToolResultItem(
|
||||
id: resultEvent.messageId,
|
||||
callId: resultEvent.toolCallId,
|
||||
uiCard: resultEvent.ui ?? UiCard(cardType: 'empty', data: {}),
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
emit(state.copyWith(items: [...filteredItems, resultItem]));
|
||||
}
|
||||
|
||||
void _handleToolCallError(ToolCallErrorEvent errorEvent) {
|
||||
_toolCallArgsBuffer.remove(errorEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
|
||||
return item.copyWith(
|
||||
status: ToolCallStatus.error,
|
||||
errorMessage: errorEvent.error,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
}
|
||||
|
||||
void _handleMessagesSnapshot(MessagesSnapshotEvent snapshotEvent) {
|
||||
final newItems = _convertSnapshotMessages(snapshotEvent.messages);
|
||||
final allItems = [...newItems, ...state.items];
|
||||
|
||||
// Determine oldest date and history availability
|
||||
DateTime? newOldestDate = state.oldestLoadedDate;
|
||||
bool newHasEarlierHistory = false;
|
||||
|
||||
if (newItems.isNotEmpty) {
|
||||
newOldestDate = _extractDateFromItems(newItems);
|
||||
if (newOldestDate != null) {
|
||||
newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate);
|
||||
}
|
||||
} else if (newOldestDate != null) {
|
||||
newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate);
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: allItems,
|
||||
oldestLoadedDate: newOldestDate,
|
||||
hasEarlierHistory: newHasEarlierHistory,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ChatListItem> _convertSnapshotMessages(List<SnapshotMessage> messages) {
|
||||
return messages.map((msg) {
|
||||
final timestamp = msg.timestamp ?? DateTime.now();
|
||||
switch (msg.role) {
|
||||
case 'user':
|
||||
return TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content ?? '',
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.user,
|
||||
);
|
||||
case 'assistant':
|
||||
return TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content ?? '',
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
case 'tool' when msg.ui != null:
|
||||
return ToolResultItem(
|
||||
id: msg.id,
|
||||
callId: msg.toolCallId ?? '',
|
||||
uiCard: msg.ui!,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
default:
|
||||
return TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content ?? '',
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
DateTime? _extractDateFromItems(List<ChatListItem> items) {
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
return items
|
||||
.map(
|
||||
(item) => DateTime(
|
||||
item.timestamp.year,
|
||||
item.timestamp.month,
|
||||
item.timestamp.day,
|
||||
),
|
||||
)
|
||||
.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final userMessage = TextMessageItem(
|
||||
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
|
||||
@@ -194,6 +286,18 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
await _service.sendMessage(content);
|
||||
}
|
||||
|
||||
Future<void> loadHistory() async {
|
||||
if (state.isLoading) return;
|
||||
await _service.loadHistory();
|
||||
}
|
||||
|
||||
Future<void> loadMoreHistory() async {
|
||||
if (state.isLoading || !state.hasEarlierHistory) return;
|
||||
if (state.oldestLoadedDate == null) return;
|
||||
|
||||
await _service.loadHistory(beforeDate: state.oldestLoadedDate);
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
emit(state.copyWith(error: null));
|
||||
}
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../shared/widgets/chat_bubble.dart';
|
||||
|
||||
enum ChatItemType { message, schedule }
|
||||
|
||||
abstract class ChatListItem {
|
||||
String get id;
|
||||
DateTime get timestamp;
|
||||
ChatItemType get type;
|
||||
MessageSender get sender;
|
||||
}
|
||||
|
||||
class HomeMockData {
|
||||
static List<ChatListItem> getTodayItems() {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
return _getMockItems().where((item) {
|
||||
final itemDate = DateTime(
|
||||
item.timestamp.year,
|
||||
item.timestamp.month,
|
||||
item.timestamp.day,
|
||||
);
|
||||
return itemDate == today;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static Future<List<ChatListItem>> loadMoreItems(DateTime beforeDate) async {
|
||||
return _getOlderMockItems(beforeDate);
|
||||
}
|
||||
|
||||
static List<ChatListItem> _getMockItems() {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final todayStart = DateTime(today.year, today.month, today.day);
|
||||
|
||||
return [
|
||||
ChatMessageItem(
|
||||
id: 'm4',
|
||||
content: '明天提醒我开会',
|
||||
timestamp: todayStart.add(const Duration(hours: 14)),
|
||||
sender: MessageSender.user,
|
||||
),
|
||||
ScheduleItemWrapper(
|
||||
id: 's1',
|
||||
scheduleItem: ScheduleItemModel(
|
||||
id: 's1',
|
||||
title: '产品评审会议',
|
||||
description: '讨论Q2产品路线图',
|
||||
startAt: todayStart.add(const Duration(days: 1, hours: 10)),
|
||||
endAt: todayStart.add(const Duration(days: 1, hours: 11)),
|
||||
timezone: 'Asia/Shanghai',
|
||||
sourceType: ScheduleSourceType.agentGenerated,
|
||||
status: ScheduleStatus.active,
|
||||
metadata: ScheduleMetadata(
|
||||
color: '#4F46E5',
|
||||
location: '会议室A / 在线',
|
||||
notes: '需要提前准备Q2数据',
|
||||
attachments: [
|
||||
Attachment(
|
||||
name: 'Q2路线图.pdf',
|
||||
type: AttachmentType.document,
|
||||
url: 'https://example.com/q2.pdf',
|
||||
),
|
||||
],
|
||||
),
|
||||
createdAt: todayStart.subtract(const Duration(hours: 5)),
|
||||
),
|
||||
timestamp: todayStart.add(const Duration(hours: 14, minutes: 2)),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
ChatMessageItem(
|
||||
id: 'm5',
|
||||
content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。',
|
||||
timestamp: todayStart.add(const Duration(hours: 14, minutes: 2)),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<ChatListItem> _getOlderMockItems(DateTime beforeDate) {
|
||||
final before = DateTime(beforeDate.year, beforeDate.month, beforeDate.day);
|
||||
final dayBefore = before.subtract(const Duration(days: 1));
|
||||
|
||||
return [
|
||||
ChatMessageItem(
|
||||
id: 'm1',
|
||||
content: '你好,我有什么可以帮你的?',
|
||||
timestamp: dayBefore.add(const Duration(hours: 10)),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
ChatMessageItem(
|
||||
id: 'm2',
|
||||
content: '下周一之前提交项目报告',
|
||||
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 55)),
|
||||
sender: MessageSender.user,
|
||||
),
|
||||
ScheduleItemWrapper(
|
||||
id: 's0',
|
||||
scheduleItem: ScheduleItemModel(
|
||||
id: 's0',
|
||||
title: '提交项目报告',
|
||||
description: '完成并提交Q2项目报告',
|
||||
startAt: before.subtract(const Duration(days: 3)),
|
||||
endAt: null,
|
||||
timezone: 'Asia/Shanghai',
|
||||
sourceType: ScheduleSourceType.agentGenerated,
|
||||
status: ScheduleStatus.active,
|
||||
metadata: ScheduleMetadata(
|
||||
color: '#F59E0B',
|
||||
location: null,
|
||||
notes: '记得附上数据附件',
|
||||
attachments: [],
|
||||
),
|
||||
createdAt: dayBefore.add(const Duration(hours: 9, minutes: 50)),
|
||||
),
|
||||
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 50)),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
ChatMessageItem(
|
||||
id: 'm3',
|
||||
content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。',
|
||||
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 50)),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ChatMessageItem extends ChatListItem {
|
||||
@override
|
||||
final String id;
|
||||
final String content;
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
@override
|
||||
final MessageSender sender;
|
||||
|
||||
ChatMessageItem({
|
||||
required this.id,
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
required this.sender,
|
||||
});
|
||||
|
||||
@override
|
||||
ChatItemType get type => ChatItemType.message;
|
||||
}
|
||||
|
||||
class ScheduleItemWrapper extends ChatListItem {
|
||||
@override
|
||||
final String id;
|
||||
final ScheduleItemModel scheduleItem;
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
@override
|
||||
final MessageSender sender;
|
||||
|
||||
ScheduleItemWrapper({
|
||||
required this.id,
|
||||
required this.scheduleItem,
|
||||
required this.timestamp,
|
||||
required this.sender,
|
||||
});
|
||||
|
||||
@override
|
||||
ChatItemType get type => ChatItemType.schedule;
|
||||
}
|
||||
|
||||
enum ScheduleSourceType { manual, imported, agentGenerated }
|
||||
|
||||
enum ScheduleStatus { active, completed, canceled, archived }
|
||||
|
||||
class ScheduleItemModel {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? description;
|
||||
final DateTime startAt;
|
||||
final DateTime? endAt;
|
||||
final String timezone;
|
||||
final ScheduleSourceType sourceType;
|
||||
final ScheduleStatus status;
|
||||
final ScheduleMetadata? metadata;
|
||||
final DateTime createdAt;
|
||||
|
||||
ScheduleItemModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.startAt,
|
||||
this.endAt,
|
||||
required this.timezone,
|
||||
required this.sourceType,
|
||||
required this.status,
|
||||
this.metadata,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ScheduleItemModel.fromJson(Map<String, dynamic> json) {
|
||||
return ScheduleItemModel(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
startAt: DateTime.parse(json['start_at']),
|
||||
endAt: json['end_at'] != null ? DateTime.parse(json['end_at']) : null,
|
||||
timezone: json['timezone'] ?? 'UTC',
|
||||
sourceType: ScheduleSourceType.values.firstWhere(
|
||||
(e) => e.name == json['source_type'],
|
||||
orElse: () => ScheduleSourceType.manual,
|
||||
),
|
||||
status: ScheduleStatus.values.firstWhere(
|
||||
(e) => e.name == json['status'],
|
||||
orElse: () => ScheduleStatus.active,
|
||||
),
|
||||
metadata: json['metadata'] != null
|
||||
? ScheduleMetadata(
|
||||
color: json['metadata']['color'],
|
||||
location: json['metadata']['location'],
|
||||
notes: json['metadata']['notes'],
|
||||
attachments:
|
||||
(json['metadata']['attachments'] as List<dynamic>?)
|
||||
?.map(
|
||||
(a) => Attachment(
|
||||
name: a['name'],
|
||||
type: a['type'] == 'document'
|
||||
? AttachmentType.document
|
||||
: AttachmentType.reminder,
|
||||
url: a['url'],
|
||||
content: a['content'],
|
||||
note: a['note'],
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
)
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScheduleMetadata {
|
||||
final String? color;
|
||||
final String? location;
|
||||
final String? notes;
|
||||
final List<Attachment> attachments;
|
||||
|
||||
ScheduleMetadata({
|
||||
this.color,
|
||||
this.location,
|
||||
this.notes,
|
||||
this.attachments = const [],
|
||||
});
|
||||
}
|
||||
|
||||
enum AttachmentType { document, reminder }
|
||||
|
||||
class Attachment {
|
||||
final String name;
|
||||
final AttachmentType type;
|
||||
final String? url;
|
||||
final String? content;
|
||||
final String? note;
|
||||
|
||||
Attachment({
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.url,
|
||||
this.content,
|
||||
this.note,
|
||||
});
|
||||
}
|
||||
|
||||
extension ScheduleSourceTypeExtension on ScheduleSourceType {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ScheduleSourceType.manual:
|
||||
return '手动创建';
|
||||
case ScheduleSourceType.imported:
|
||||
return '导入';
|
||||
case ScheduleSourceType.agentGenerated:
|
||||
return 'AI生成';
|
||||
}
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case ScheduleSourceType.manual:
|
||||
return Icons.edit_calendar;
|
||||
case ScheduleSourceType.imported:
|
||||
return Icons.download;
|
||||
case ScheduleSourceType.agentGenerated:
|
||||
return Icons.auto_awesome;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ const _defaultPadding = 20.0;
|
||||
const _itemSpacing = 16.0;
|
||||
const _inputPadding = 16.0;
|
||||
const _iconSize = 24.0;
|
||||
const _avatarSize = 32.0;
|
||||
const _botIconSize = 18.0;
|
||||
const _messagePaddingH = 13.0;
|
||||
const _messagePaddingV = 9.0;
|
||||
const _cornerRadius = 12.0;
|
||||
@@ -39,6 +37,7 @@ class HomeScreen extends StatefulWidget {
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final ChatBloc _chatBloc;
|
||||
|
||||
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
||||
|
||||
@@ -46,6 +45,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageController.addListener(_onMessageChanged);
|
||||
_chatBloc = ChatBloc();
|
||||
_chatBloc.loadHistory();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -53,6 +54,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_messageController.removeListener(_onMessageChanged);
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_chatBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -62,8 +64,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => ChatBloc(),
|
||||
return BlocProvider.value(
|
||||
value: _chatBloc,
|
||||
child: BlocConsumer<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
if (state.error != null) {
|
||||
@@ -132,6 +134,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Widget _buildChatArea(BuildContext context, ChatState state) {
|
||||
if (state.isLoading && state.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.items.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
@@ -141,30 +147,96 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: _scrollDurationMs),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _onRefresh(context),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(_defaultPadding),
|
||||
itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0 && state.hasEarlierHistory) {
|
||||
return _buildLoadMoreButton(context, state.isLoading);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(_defaultPadding),
|
||||
itemCount: state.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.items[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: _itemSpacing),
|
||||
child: _buildChatItem(item),
|
||||
);
|
||||
},
|
||||
final itemIndex = state.hasEarlierHistory ? index - 1 : index;
|
||||
final item = state.items[itemIndex];
|
||||
|
||||
final showDateDivider =
|
||||
itemIndex == 0 ||
|
||||
!_isSameDay(state.items[itemIndex - 1].timestamp, item.timestamp);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (showDateDivider) _buildDateDivider(item.timestamp),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: _itemSpacing),
|
||||
child: _buildChatItem(item),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
Widget _buildDateDivider(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
final weekday = weekdays[date.weekday - 1];
|
||||
|
||||
// For all dates (today/yesterday/this year), use the same format
|
||||
// Only add year prefix for dates from previous years
|
||||
final label = date.year == now.year
|
||||
? '${date.month}月${date.day}日 $weekday'
|
||||
: '${date.year}年${date.month}月${date.day}日 $weekday';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.slate400),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadMoreButton(BuildContext context, bool isLoading) {
|
||||
return GestureDetector(
|
||||
onTap: isLoading ? null : () => _onLoadMore(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
alignment: Alignment.center,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'查看历史',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate400),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRefresh(BuildContext context) async {
|
||||
await context.read<ChatBloc>().loadMoreHistory();
|
||||
}
|
||||
|
||||
void _onLoadMore(BuildContext context) {
|
||||
context.read<ChatBloc>().loadMoreHistory();
|
||||
}
|
||||
|
||||
Widget _buildChatItem(ChatListItem item) {
|
||||
switch (item.type) {
|
||||
case ChatItemType.message:
|
||||
@@ -182,24 +254,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
mainAxisAlignment: isUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
Container(
|
||||
width: _avatarSize,
|
||||
height: _avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue100,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.bot,
|
||||
size: _botIconSize,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -222,8 +278,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser) const SizedBox(width: 40),
|
||||
if (!isUser) const SizedBox(width: 40),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -365,6 +419,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
if (content.isEmpty) return;
|
||||
_messageController.clear();
|
||||
context.read<ChatBloc>().sendMessage(content);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: _scrollDurationMs),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showBottomSheet(BuildContext context) {
|
||||
|
||||
@@ -2,51 +2,33 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||
import 'package:social_app/shared/widgets/chat_bubble.dart';
|
||||
|
||||
void main() {
|
||||
group('HomeScreen Widget Tests', () {
|
||||
testWidgets('displays chat messages with ChatBubble', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||
|
||||
expect(find.byType(ChatBubble), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('displays user request message', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||
|
||||
expect(find.textContaining('明天提醒我开会'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays AI response message', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||
|
||||
expect(find.textContaining('已为你创建日程'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays calendar schedule cards in chat flow', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||
|
||||
expect(find.byType(ChatBubble), findsAtLeastNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('input field is present', (WidgetTester tester) async {
|
||||
testWidgets('displays input field', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('输入消息...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('header icons are present', (WidgetTester tester) async {
|
||||
testWidgets('displays header icons', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(LucideIcons.settings), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.calendar), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.messageSquare), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays send or mic icon based on input', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(LucideIcons.mic), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user