refactor(chat): 重构聊天模块并集成历史消息加载功能

- 删除冗余的 chat_history_repository 和 home_mock_data
- 简化 ag_ui_event fromJson 使用工厂映射表
- 提取 ChatBloc 事件处理方法,添加 loadHistory/loadMoreHistory
- HomeScreen 集成 ChatBloc 实现历史消息加载和下拉刷新
- 更新 AGENTS.md 文档约束
This commit is contained in:
qzl
2026-03-02 15:05:10 +08:00
parent 6b32990986
commit e161ca22c4
16 changed files with 915 additions and 752 deletions
+1
View File
@@ -37,6 +37,7 @@ Follow this hierarchy when developing:
- Default branch: `dev` - Default branch: `dev`
- Feature development: use worktree `git worktree add -b feature/xxx ../feature-xxx dev` - Feature development: use worktree `git worktree add -b feature/xxx ../feature-xxx dev`
- Never develop directly on `main` - Never develop directly on `main`
- **Never push to remote unless explicitly requested by user**
## Supabase Services ## Supabase Services
+51 -155
View File
@@ -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 | - **MUST** prefer existing components and established page patterns over creating new UI components.
|------|-------| - **MUST** use:
| Colors | `AppColors.primary`, `AppColors.slate500`, `AppColors.background` | - Buttons: `AppButton` from `apps/lib/shared/widgets/app_button.dart`
| Spacing | `AppSpacing.xs`, `AppSpacing.sm`, `AppSpacing.md` | - **MUST NOT** introduce parallel UI systems (custom buttons, custom loading systems, custom input wrappers) unless explicitly required and approved.
| Radius | `AppRadius.sm`, `AppRadius.md`, `AppRadius.lg` |
**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: ## 4) Centering & Visual Balance (MUST)
- 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
## 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: ## 5) Quality Gate for Important Screens (MUST)
- Layout structure (centered form, padding, spacing)
- Typography hierarchy (title 28px bold, label 13px, hint 14px)
- Component usage (AppButton, TextField style)
- Color and spacing tokens
2. **Use frontend-design skill for mockups**: For important screens:
```
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
```
3. **Verify design tokens**: - **MUST** add widget tests to reduce layout regression risk:
- All colors from `AppColors` - Verify primary content stays centered relative to the usable viewport.
- All spacing from `AppSpacing` - Include at least one constrained scenario (e.g., small height **or** large text scale).
- All radius from `AppRadius`
- NO hardcoded values
4. **Code review checklist**: ## 6) UI Feedback System (MUST)
- [ ] All colors/spacing/radius use design tokens
- [ ] Reuses existing components (AppButton)
- [ ] Consistent with existing page patterns
- [ ] No magic numbers
## 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`**: Agent chat functionality **MUST** follow the AG-UI protocol: `docs/knowledges/ag-ui-llms-full.txt`.
- `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.
## 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. ## 8) Debugging Behavior (MUST)
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.
## Quality Gate - **MUST NOT** automatically start Flutter app debugging or running.
- After code changes, **MUST** instruct the user to run manually (user-controlled):
For important screens, add widget tests that reduce layout-regression risk: - `flutter run --dart-define=MOCK_API=true -d emulator-5554`
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.
@@ -34,6 +34,7 @@ enum AgUiEventType {
unknown, unknown,
} }
// wire 类型到枚举的映射
const _wireToTypeMap = { const _wireToTypeMap = {
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted, AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished, AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
@@ -49,6 +50,7 @@ const _wireToTypeMap = {
AgUiEventTypeWire.messagesSnapshot: AgUiEventType.messagesSnapshot, AgUiEventTypeWire.messagesSnapshot: AgUiEventType.messagesSnapshot,
}; };
// 枚举到 wire 类型的映射
const _typeToWireMap = { const _typeToWireMap = {
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted, AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished, AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
@@ -70,6 +72,23 @@ AgUiEventType agUiEventTypeFromWire(String wire) =>
String agUiEventTypeToWire(AgUiEventType type) => _typeToWireMap[type] ?? ''; 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() @JsonSerializable()
class AgUiEvent { class AgUiEvent {
final AgUiEventType type; final AgUiEventType type;
@@ -79,35 +98,7 @@ class AgUiEvent {
factory AgUiEvent.fromJson(Map<String, dynamic> json) { factory AgUiEvent.fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String? ?? ''; final typeStr = json['type'] as String? ?? '';
final type = agUiEventTypeFromWire(typeStr); final type = agUiEventTypeFromWire(typeStr);
return _typeToFactory[type]?.call(json) ?? UnknownAgUiEvent.fromJson(json);
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);
}
} }
Map<String, dynamic> toJson() => _$AgUiEventToJson(this); Map<String, dynamic> toJson() => _$AgUiEventToJson(this);
@@ -322,6 +313,7 @@ class SnapshotMessage {
final String? content; final String? content;
final String? toolCallId; final String? toolCallId;
final UiCard? ui; final UiCard? ui;
final DateTime? timestamp;
SnapshotMessage({ SnapshotMessage({
required this.id, required this.id,
@@ -329,6 +321,7 @@ class SnapshotMessage {
this.content, this.content,
this.toolCallId, this.toolCallId,
this.ui, this.ui,
this.timestamp,
}); });
factory SnapshotMessage.fromJson(Map<String, dynamic> json) => factory SnapshotMessage.fromJson(Map<String, dynamic> json) =>
@@ -25,6 +25,7 @@ const _$AgUiEventTypeEnumMap = {
AgUiEventType.toolCallEnd: 'toolCallEnd', AgUiEventType.toolCallEnd: 'toolCallEnd',
AgUiEventType.toolCallResult: 'toolCallResult', AgUiEventType.toolCallResult: 'toolCallResult',
AgUiEventType.toolCallError: 'toolCallError', AgUiEventType.toolCallError: 'toolCallError',
AgUiEventType.messagesSnapshot: 'messagesSnapshot',
AgUiEventType.unknown: 'unknown', AgUiEventType.unknown: 'unknown',
}; };
@@ -157,3 +158,39 @@ Map<String, dynamic> _$ToolCallErrorEventToJson(ToolCallErrorEvent instance) =>
'error': instance.error, 'error': instance.error,
'code': instance.code, '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( UiCard _$UiCardFromJson(Map<String, dynamic> json) => UiCard(
cardType: json['type'] as String, cardType: json['type'] as String,
schemaVersion: json['version'] as String? ?? 'v1', schemaVersion: json['version'] as String? ?? _defaultSchemaVersion,
data: json['data'] as Map<String, dynamic>, data: json['data'] as Map<String, dynamic>,
actions: (json['actions'] as List<dynamic>?) actions: (json['actions'] as List<dynamic>?)
?.map((e) => CardAction.fromJson(e as Map<String, 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 { Future<void> loadHistory({DateTime? beforeDate}) async {
if (Env.isMockApi) { if (Env.isMockApi) {
await _mockLoadHistory(beforeDate: beforeDate); await _mockLoadHistory(beforeDate: beforeDate);
} else {
throw UnimplementedError('Real API not implemented');
} }
} }
@@ -58,8 +56,10 @@ class AgUiService {
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}'; final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
onEvent(RunStartedEvent(threadId: threadId, runId: runId)); 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) { if (beforeDate != null) {
final prevDate = _historyService.getPreviousDay(beforeDate); final prevDate = _historyService.getPreviousDay(beforeDate);
if (prevDate == null) { if (prevDate == null) {
@@ -72,9 +72,8 @@ class AgUiService {
} }
final messages = _historyService.getHistoryForDay(targetDate); final messages = _historyService.getHistoryForDay(targetDate);
onEvent(MessagesSnapshotEvent(messages: messages)); onEvent(MessagesSnapshotEvent(messages: messages));
await Future.delayed(const Duration(milliseconds: 10));
onEvent(RunFinishedEvent(threadId: threadId, runId: runId)); onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
} }
@@ -6,40 +6,35 @@ class MockHistoryService {
factory MockHistoryService() => _instance; factory MockHistoryService() => _instance;
MockHistoryService._internal(); MockHistoryService._internal();
/// Normalize DateTime to date-only (midnight)
DateTime _toDateOnly(DateTime date) =>
DateTime(date.year, date.month, date.day);
List<SnapshotMessage> getHistoryForDay(DateTime date) { List<SnapshotMessage> getHistoryForDay(DateTime date) {
final dayStart = DateTime(date.year, date.month, date.day); final dayStart = _toDateOnly(date);
final allHistory = _generateAllHistory(); final allHistory = _generateAllHistory();
return allHistory.where((msg) { return allHistory.where((msg) {
if (msg.ui != null) { if (msg.timestamp == null) return false;
final data = msg.ui!.data; final msgDate = _toDateOnly(msg.timestamp!);
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; return msgDate == dayStart;
} catch (_) {}
}
}
return false;
}).toList(); }).toList();
} }
DateTime? getLatestHistoryDate() { DateTime? getLatestHistoryDate() {
final now = DateTime.now(); final allHistory = _generateAllHistory();
return DateTime(now.year, now.month, now.day); 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) { DateTime? getPreviousDay(DateTime currentDate) {
final allDates = _getAllHistoryDates(); final allDates = _getAllHistoryDates();
final sortedDates = allDates.toList()..sort((a, b) => b.compareTo(a)); final sortedDates = allDates.toList()..sort((a, b) => b.compareTo(a));
final currentDateOnly = _toDateOnly(currentDate);
final currentDateOnly = DateTime(
currentDate.year,
currentDate.month,
currentDate.day,
);
for (final date in sortedDates) { for (final date in sortedDates) {
if (date.isBefore(currentDateOnly)) { if (date.isBefore(currentDateOnly)) {
@@ -51,29 +46,35 @@ class MockHistoryService {
bool hasEarlierHistory(DateTime fromDate) { bool hasEarlierHistory(DateTime fromDate) {
final allDates = _getAllHistoryDates(); final allDates = _getAllHistoryDates();
final fromDateOnly = DateTime(fromDate.year, fromDate.month, fromDate.day); final fromDateOnly = _toDateOnly(fromDate);
return allDates.any((date) => date.isBefore(fromDateOnly)); return allDates.any((date) => date.isBefore(fromDateOnly));
} }
Set<DateTime> _getAllHistoryDates() { Set<DateTime> _getAllHistoryDates() {
final now = DateTime.now(); 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)); final yesterday = today.subtract(const Duration(days: 1));
return {today, yesterday}; return {today, yesterday};
} }
List<SnapshotMessage> _generateAllHistory() { List<SnapshotMessage> _generateAllHistory() {
final now = DateTime.now(); 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)); final yesterday = today.subtract(const Duration(days: 1));
return [ return [
SnapshotMessage(id: 'hist-m1', role: 'user', content: '明天提醒我开会'), SnapshotMessage(
id: 'hist-m1',
role: 'user',
content: '明天提醒我开会',
timestamp: today.add(const Duration(hours: 10)),
),
SnapshotMessage( SnapshotMessage(
id: 'hist-t1', id: 'hist-t1',
role: 'tool', role: 'tool',
toolCallId: 'hist-tc1', toolCallId: 'hist-tc1',
timestamp: today.add(const Duration(hours: 10)),
ui: UiCard( ui: UiCard(
cardType: 'calendar_card.v1', cardType: 'calendar_card.v1',
data: CalendarCardData( data: CalendarCardData(
@@ -104,21 +105,26 @@ class MockHistoryService {
id: 'hist-m2', id: 'hist-m2',
role: 'assistant', role: 'assistant',
content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。', 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( SnapshotMessage(
id: 'hist-t2', id: 'hist-t2',
role: 'tool', role: 'tool',
toolCallId: 'hist-tc2', toolCallId: 'hist-tc2',
timestamp: yesterday.add(const Duration(hours: 14)),
ui: UiCard( ui: UiCard(
cardType: 'calendar_card.v1', cardType: 'calendar_card.v1',
data: CalendarCardData( data: CalendarCardData(
id: 'hist-s2', id: 'hist-s2',
title: '提交项目报告', title: '提交项目报告',
description: '完成并提交Q2项目报告', description: '完成并提交Q2项目报告',
startAt: yesterday startAt: yesterday.add(const Duration(days: 5)).toIso8601String(),
.subtract(const Duration(days: 3))
.toIso8601String(),
endAt: null, endAt: null,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
location: null, location: null,
@@ -138,11 +144,13 @@ class MockHistoryService {
id: 'hist-m4', id: 'hist-m4',
role: 'assistant', role: 'assistant',
content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。', content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。',
timestamp: yesterday.add(const Duration(hours: 14)),
), ),
SnapshotMessage( SnapshotMessage(
id: 'hist-m5', id: 'hist-m5',
role: 'assistant', role: 'assistant',
content: '你好,我有什么可以帮你的?', content: '你好,我有什么可以帮你的?',
timestamp: yesterday.add(const Duration(hours: 9)),
), ),
]; ];
} }
@@ -63,16 +63,35 @@ class ChatBloc extends Cubit<ChatState> {
switch (event.type) { switch (event.type) {
case AgUiEventType.runStarted: case AgUiEventType.runStarted:
emit(state.copyWith(isLoading: true, error: null)); emit(state.copyWith(isLoading: true, error: null));
break;
case AgUiEventType.runFinished: case AgUiEventType.runFinished:
emit(state.copyWith(isLoading: false, currentMessageId: null)); emit(state.copyWith(isLoading: false, currentMessageId: null));
break;
case AgUiEventType.runError: case AgUiEventType.runError:
final errorEvent = event as RunErrorEvent; final errorEvent = event as RunErrorEvent;
emit(state.copyWith(isLoading: false, error: errorEvent.message)); emit(state.copyWith(isLoading: false, error: errorEvent.message));
break;
case AgUiEventType.textMessageStart: case AgUiEventType.textMessageStart:
final startEvent = event as TextMessageStartEvent; _handleTextMessageStart(event as TextMessageStartEvent);
case AgUiEventType.textMessageContent:
_handleTextMessageContent(event as TextMessageContentEvent);
case AgUiEventType.textMessageEnd:
_handleTextMessageEnd(event as TextMessageEndEvent);
case AgUiEventType.toolCallStart:
_handleToolCallStart(event as ToolCallStartEvent);
case AgUiEventType.toolCallArgs:
_handleToolCallArgs(event as ToolCallArgsEvent);
case AgUiEventType.toolCallEnd:
_handleToolCallEnd(event as ToolCallEndEvent);
case AgUiEventType.toolCallResult:
_handleToolCallResult(event as ToolCallResultEvent);
case AgUiEventType.toolCallError:
_handleToolCallError(event as ToolCallErrorEvent);
case AgUiEventType.messagesSnapshot:
_handleMessagesSnapshot(event as MessagesSnapshotEvent);
case AgUiEventType.unknown:
break;
}
}
void _handleTextMessageStart(TextMessageStartEvent startEvent) {
final newMessage = TextMessageItem( final newMessage = TextMessageItem(
id: startEvent.messageId, id: startEvent.messageId,
content: '', content: '',
@@ -86,9 +105,9 @@ class ChatBloc extends Cubit<ChatState> {
currentMessageId: startEvent.messageId, currentMessageId: startEvent.messageId,
), ),
); );
break; }
case AgUiEventType.textMessageContent:
final contentEvent = event as TextMessageContentEvent; void _handleTextMessageContent(TextMessageContentEvent contentEvent) {
final updatedItems = state.items.map((item) { final updatedItems = state.items.map((item) {
if (item.id == contentEvent.messageId && item is TextMessageItem) { if (item.id == contentEvent.messageId && item is TextMessageItem) {
return item.copyWith(content: item.content + contentEvent.delta); return item.copyWith(content: item.content + contentEvent.delta);
@@ -96,9 +115,9 @@ class ChatBloc extends Cubit<ChatState> {
return item; return item;
}).toList(); }).toList();
emit(state.copyWith(items: updatedItems)); emit(state.copyWith(items: updatedItems));
break; }
case AgUiEventType.textMessageEnd:
final endEvent = event as TextMessageEndEvent; void _handleTextMessageEnd(TextMessageEndEvent endEvent) {
final updatedItems = state.items.map((item) { final updatedItems = state.items.map((item) {
if (item.id == endEvent.messageId && item is TextMessageItem) { if (item.id == endEvent.messageId && item is TextMessageItem) {
return item.copyWith(isStreaming: false); return item.copyWith(isStreaming: false);
@@ -106,9 +125,9 @@ class ChatBloc extends Cubit<ChatState> {
return item; return item;
}).toList(); }).toList();
emit(state.copyWith(items: updatedItems, currentMessageId: null)); emit(state.copyWith(items: updatedItems, currentMessageId: null));
break; }
case AgUiEventType.toolCallStart:
final startEvent = event as ToolCallStartEvent; void _handleToolCallStart(ToolCallStartEvent startEvent) {
_toolCallArgsBuffer[startEvent.toolCallId] = ''; _toolCallArgsBuffer[startEvent.toolCallId] = '';
final newToolCall = ToolCallItem( final newToolCall = ToolCallItem(
id: startEvent.toolCallId, id: startEvent.toolCallId,
@@ -120,14 +139,14 @@ class ChatBloc extends Cubit<ChatState> {
sender: MessageSender.ai, sender: MessageSender.ai,
); );
emit(state.copyWith(items: [...state.items, newToolCall])); emit(state.copyWith(items: [...state.items, newToolCall]));
break; }
case AgUiEventType.toolCallArgs:
final argsEvent = event as ToolCallArgsEvent; void _handleToolCallArgs(ToolCallArgsEvent argsEvent) {
_toolCallArgsBuffer[argsEvent.toolCallId] = _toolCallArgsBuffer[argsEvent.toolCallId] =
(_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta; (_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta;
break; }
case AgUiEventType.toolCallEnd:
final endEvent = event as ToolCallEndEvent; void _handleToolCallEnd(ToolCallEndEvent endEvent) {
final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? ''; final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? '';
Map<String, dynamic> parsedArgs = {}; Map<String, dynamic> parsedArgs = {};
if (argsBuffer.isNotEmpty) { if (argsBuffer.isNotEmpty) {
@@ -146,9 +165,9 @@ class ChatBloc extends Cubit<ChatState> {
return item; return item;
}).toList(); }).toList();
emit(state.copyWith(items: updatedItems)); emit(state.copyWith(items: updatedItems));
break; }
case AgUiEventType.toolCallResult:
final resultEvent = event as ToolCallResultEvent; void _handleToolCallResult(ToolCallResultEvent resultEvent) {
final filteredItems = state.items.where((item) { final filteredItems = state.items.where((item) {
if (item.id == resultEvent.toolCallId && item is ToolCallItem) { if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
return false; return false;
@@ -163,9 +182,9 @@ class ChatBloc extends Cubit<ChatState> {
sender: MessageSender.ai, sender: MessageSender.ai,
); );
emit(state.copyWith(items: [...filteredItems, resultItem])); emit(state.copyWith(items: [...filteredItems, resultItem]));
break; }
case AgUiEventType.toolCallError:
final errorEvent = event as ToolCallErrorEvent; void _handleToolCallError(ToolCallErrorEvent errorEvent) {
_toolCallArgsBuffer.remove(errorEvent.toolCallId); _toolCallArgsBuffer.remove(errorEvent.toolCallId);
final updatedItems = state.items.map((item) { final updatedItems = state.items.map((item) {
if (item.id == errorEvent.toolCallId && item is ToolCallItem) { if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
@@ -177,10 +196,83 @@ class ChatBloc extends Cubit<ChatState> {
return item; return item;
}).toList(); }).toList();
emit(state.copyWith(items: updatedItems)); emit(state.copyWith(items: updatedItems));
break;
case AgUiEventType.unknown:
break;
} }
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 { Future<void> sendMessage(String content) async {
@@ -194,6 +286,18 @@ class ChatBloc extends Cubit<ChatState> {
await _service.sendMessage(content); 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() { void clearError() {
emit(state.copyWith(error: null)); 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 _itemSpacing = 16.0;
const _inputPadding = 16.0; const _inputPadding = 16.0;
const _iconSize = 24.0; const _iconSize = 24.0;
const _avatarSize = 32.0;
const _botIconSize = 18.0;
const _messagePaddingH = 13.0; const _messagePaddingH = 13.0;
const _messagePaddingV = 9.0; const _messagePaddingV = 9.0;
const _cornerRadius = 12.0; const _cornerRadius = 12.0;
@@ -39,6 +37,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
final TextEditingController _messageController = TextEditingController(); final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc;
bool get _hasMessage => _messageController.text.trim().isNotEmpty; bool get _hasMessage => _messageController.text.trim().isNotEmpty;
@@ -46,6 +45,8 @@ class _HomeScreenState extends State<HomeScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_messageController.addListener(_onMessageChanged); _messageController.addListener(_onMessageChanged);
_chatBloc = ChatBloc();
_chatBloc.loadHistory();
} }
@override @override
@@ -53,6 +54,7 @@ class _HomeScreenState extends State<HomeScreen> {
_messageController.removeListener(_onMessageChanged); _messageController.removeListener(_onMessageChanged);
_messageController.dispose(); _messageController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_chatBloc.close();
super.dispose(); super.dispose();
} }
@@ -62,8 +64,8 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider.value(
create: (context) => ChatBloc(), value: _chatBloc,
child: BlocConsumer<ChatBloc, ChatState>( child: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) { listener: (context, state) {
if (state.error != null) { if (state.error != null) {
@@ -132,6 +134,10 @@ class _HomeScreenState extends State<HomeScreen> {
} }
Widget _buildChatArea(BuildContext context, ChatState state) { Widget _buildChatArea(BuildContext context, ChatState state) {
if (state.isLoading && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.items.isEmpty) { if (state.items.isEmpty) {
return const Center( return const Center(
child: Text( child: Text(
@@ -141,30 +147,96 @@ class _HomeScreenState extends State<HomeScreen> {
); );
} }
WidgetsBinding.instance.addPostFrameCallback((_) { return RefreshIndicator(
if (_scrollController.hasClients) { onRefresh: () => _onRefresh(context),
_scrollController.animateTo( child: ListView.builder(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: _scrollDurationMs),
curve: Curves.easeOut,
);
}
});
return ListView.builder(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(_defaultPadding), padding: const EdgeInsets.all(_defaultPadding),
itemCount: state.items.length, itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = state.items[index]; if (index == 0 && state.hasEarlierHistory) {
return Padding( return _buildLoadMoreButton(context, state.isLoading);
}
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), padding: const EdgeInsets.only(bottom: _itemSpacing),
child: _buildChatItem(item), 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) { Widget _buildChatItem(ChatListItem item) {
switch (item.type) { switch (item.type) {
case ChatItemType.message: case ChatItemType.message:
@@ -182,24 +254,8 @@ class _HomeScreenState extends State<HomeScreen> {
mainAxisAlignment: isUser mainAxisAlignment: isUser
? MainAxisAlignment.end ? MainAxisAlignment.end
: MainAxisAlignment.start, : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( Flexible(
child: Container( child: Container(
padding: const EdgeInsets.symmetric( 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; if (content.isEmpty) return;
_messageController.clear(); _messageController.clear();
context.read<ChatBloc>().sendMessage(content); 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) { void _showBottomSheet(BuildContext context) {
@@ -2,51 +2,33 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/features/home/ui/screens/home_screen.dart'; import 'package:social_app/features/home/ui/screens/home_screen.dart';
import 'package:social_app/shared/widgets/chat_bubble.dart';
void main() { void main() {
group('HomeScreen Widget Tests', () { group('HomeScreen Widget Tests', () {
testWidgets('displays chat messages with ChatBubble', ( testWidgets('displays input field', (WidgetTester tester) async {
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 {
await tester.pumpWidget(const MaterialApp(home: HomeScreen())); await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget); expect(find.byType(TextField), findsOneWidget);
expect(find.text('输入消息...'), 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.pumpWidget(const MaterialApp(home: HomeScreen()));
await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.settings), findsOneWidget); expect(find.byIcon(LucideIcons.settings), findsOneWidget);
expect(find.byIcon(LucideIcons.calendar), findsOneWidget); expect(find.byIcon(LucideIcons.calendar), findsOneWidget);
expect(find.byIcon(LucideIcons.messageSquare), 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);
});
}); });
} }
+4
View File
@@ -206,3 +206,7 @@ class AgentType(str, Enum):
- [ ] Policy target roles are explicit (`anon`, `authenticated`, or both) - [ ] Policy target roles are explicit (`anon`, `authenticated`, or both)
- [ ] Downgrade path is reversible and does not silently weaken intended production security - [ ] Downgrade path is reversible and does not silently weaken intended production security
- [ ] Any exemption is documented with clear non-exposure evidence - [ ] Any exemption is documented with clear non-exposure evidence
## Agent Loop (AG-UI Protocol)
Agent loop functionality MUST follow the AG-UI protocol. Reference: `docs/knowledges/ag-ui-llms-full.txt`
+39
View File
@@ -33,3 +33,42 @@
- `backend/src/models/profile.py` - `backend/src/models/profile.py`
--- ---
## Flutter Design Tokens
### [TOKEN-001] 大量硬编码颜色违反 AGENTS.md 规则
**Status**: Pending
**Priority**: Medium
**Created**: 2026-03-02
**Description**:
`apps/AGENTS.md` 规则要求禁止硬编码颜色,必须使用 `design_tokens.dart` 中的 `AppColors`。但实际代码中存在大量硬编码。
**Current Behavior**:
- `apps/AGENTS.md` 规定:"NEVER hardcode colors, sizes, or spacing values"
- 代码中有 **109 处**硬编码 `Color(0xFF...)` 分布在:
- `register_screen.dart`, `register_verification_screen.dart`
- `settings_screen.dart`, `account_screen.dart`
- `contacts_screen.dart`, `calendar_event_detail_screen.dart`
- `add_contact_screen.dart`, `features_screen.dart`
- `memory_screen.dart`, `home_screen.dart`
- `todo_detail_screen.dart`
**Expected Behavior**:
所有颜色应使用 `AppColors` 中定义的值。
**Impact**:
- 与项目规范不一致
- 后续 theme 统一修改困难
- 代码审查难以发现
**Implementation Options****:
1. **保守方案**:将常用硬编码颜色添加到 `AppColors`,逐步迁移
2. **激进方案**:重构所有页面使用 tokens
3. **规则调整**:如果某些场景确实需要硬编码(如动态颜色),修改 AGENTS.md 明确允许场景
**Related Files**:
- `apps/lib/core/theme/design_tokens.dart`
- `apps/AGENTS.md`
- 各 feature 页面
+393
View File
@@ -0,0 +1,393 @@
# Database Schema
**Status:** Active
**Reference:** [Plan: social-app 数据模型重设计](../plans/2026-02-26-social-data-model-redesign.md)
---
## 枚举约定
所有枚举使用字符串存储,不使用整数值:
- Database: `VARCHAR(20)` + `CHECK` 约束
- Code: Python `Enum` 继承 `str`
---
## 表清单
| 表名 | 说明 |
|------|------|
| `profiles` | 用户资料(含 settings JSONB |
| `user_agents` | 用户专属 Agent |
| `memories` | 用户/工作记忆 |
| `friendships` | 好友关系 |
| `groups` | 群组 |
| `group_members` | 群组成员 |
| `schedule_items` | 日程事项 |
| `schedule_subscriptions` | 日程订阅与权限 |
| `inbox_messages` | 待处理消息 |
| `todos` | 待办 |
| `todo_sources` | 待办与日程来源关联 |
| `automation_jobs` | 定时任务 |
| `sessions` | Agent 对话会话 |
| `llm_factories` | LLM 工厂配置 |
| `llms` | LLM 模型实例 |
---
## 表结构
### profiles
用户资料表,含内置设置。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK`auth.users.id` |
| `username` | VARCHAR(50) | 用户名 |
| `avatar_url` | TEXT | 头像 URL |
| `bio` | TEXT | 个人简介 |
| `settings` | JSONB | 用户设置 |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
| `deleted_at` | TIMESTAMPTZ | 软删时间 |
**settings JSONB 默认结构:**
```json
{
"version": 1,
"preferences": {
"interface_language": "zh-CN",
"ai_language": "zh-CN",
"timezone": "Asia/Shanghai"
},
"privacy": {},
"notification": {}
}
```
---
### user_agents
用户专属 Agent 配置。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `user_id` | UUID | 用户 ID(唯一) |
| `llm_id` | UUID | 关联的 LLM 模型 |
| `agent_type` | VARCHAR(20) | 枚举:`INTENT_RECOGNITION`, `TASK_EXECUTION`, `RESULT_REPORTING` |
| `config` | JSONB | Agent 配置参数 |
| `status` | VARCHAR(20) | 状态:`active`, `paused`, `migrating` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
| `deleted_at` | TIMESTAMPTZ | 软删时间 |
---
### memories
用户与工作记忆。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `owner_id` | UUID | 用户 ID |
| `agent_id` | UUID | Agent IDwork 类型必填) |
| `memory_type` | VARCHAR(20) | 枚举:`user`, `work` |
| `title` | VARCHAR(255) | 标题 |
| `content` | JSONB | 记忆内容 |
| `source` | VARCHAR(20) | 来源:`manual`, `agent`, `imported` |
| `status` | VARCHAR(20) | 状态:`active`, `disabled` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
**约束:** work 类型必须有 agent_iduser 类型必须无 agent_id
**content JSONB 示例:**
```json
// 用户记忆
{"type": "preference", "data": {"style": "concise", "language": "zh-CN"}}
// 工作记忆
{"type": "workflow_summary", "data": {"task": "代码审查", "learnings": ["优先检查安全漏洞"], "improvements": []}}
```
---
### friendships
好友关系(双向规范化)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `user_low_id` | UUID | 较小 UUID |
| `user_high_id` | UUID | 较大 UUID |
| `initiator_id` | UUID | 发起方用户 ID |
| `status` | VARCHAR(20) | 状态:`pending`, `accepted`, `blocked`, `declined`, `canceled` |
| `requested_at` | TIMESTAMPTZ | 请求时间 |
| `accepted_at` | TIMESTAMPTZ | 接受时间 |
| `blocked_by` | UUID | 阻止者用户 ID |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
**约束:** `user_low_id < user_high_id``(user_low_id, user_high_id)` 唯一
---
### groups
群组。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `name` | VARCHAR(100) | 群组名称 |
| `description` | TEXT | 群组描述 |
| `owner_id` | UUID | 创建者 ID |
| `status` | VARCHAR(20) | 状态:`active`, `archived` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
| `deleted_at` | TIMESTAMPTZ | 软删时间 |
---
### group_members
群组成员。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `group_id` | UUID | 群组 ID |
| `user_id` | UUID | 用户 ID |
| `role` | VARCHAR(20) | 角色:`owner`, `admin`, `member` |
| `join_source` | VARCHAR(20) | 加入方式:`invited`, `joined` |
| `invited_by` | UUID | 邀请人 ID |
| `joined_at` | TIMESTAMPTZ | 加入时间 |
| `status` | VARCHAR(20) | 状态:`active`, `muted`, `removed` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
| `removed_at` | TIMESTAMPTZ | 移除时间 |
**约束:** `(group_id, user_id)` 唯一
---
### schedule_items
日程事项。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `owner_id` | UUID | 所有者 ID |
| `title` | VARCHAR(255) | 标题 |
| `description` | TEXT | 描述 |
| `start_at` | TIMESTAMPTZ | 开始时间 |
| `end_at` | TIMESTAMPTZ | 结束时间 |
| `timezone` | VARCHAR(50) | 时区 |
| `metadata` | JSONB | 扩展字段 |
| `recurrence_rule` | VARCHAR(100) | 循环规则 |
| `source_type` | VARCHAR(20) | 来源:`manual`, `imported`, `agent_generated` |
| `status` | VARCHAR(20) | 状态:`active`, `completed`, `canceled`, `archived` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
**metadata JSONB 默认结构:**
```json
{
"color": "#FF6B6B",
"location": "会议室A",
"notes": "记得提前准备投影仪",
"attachments": [
{
"name": "会议纪要.pdf",
"url": "https://...",
"visible_to": [],
"type": "document"
},
{
"name": "投影仪提醒",
"visible_to": ["uuid1"],
"type": "reminder",
"content": "记得带投影仪"
}
],
"version": 1
}
```
---
### schedule_subscriptions
日程订阅与权限。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `item_id` | UUID | 日程事项 ID |
| `subscriber_id` | UUID | 订阅者 ID |
| `permission` | INTEGER | 权限位图(view=1, invite=2, edit=4 |
| `notify_level` | VARCHAR(20) | 通知级别:`all`, `mentions`, `none` |
| `status` | VARCHAR(20) | 状态:`active`, `paused`, `unsubscribed` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
**约束:** `(item_id, subscriber_id)` 唯一,`permission BETWEEN 0 AND 7`
---
### inbox_messages
待处理消息(接收者视角)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `recipient_id` | UUID | 接收者 ID |
| `sender_id` | UUID | 发送者 ID(系统消息可为 NULL) |
| `message_type` | VARCHAR(20) | 类型:`friend_request`, `calendar`, `system`, `group` |
| `friendship_id` | UUID | 好友请求关联(friend_request 时必填) |
| `schedule_item_id` | UUID | 日程关联(calendar 时必填) |
| `group_id` | UUID | 群组关联(group 时必填) |
| `content` | TEXT | 消息内容(system 用) |
| `is_read` | BOOLEAN | 是否已读 |
| `status` | VARCHAR(20) | 状态:`pending`, `accepted`, `rejected`, `dismissed` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
**message_type 与业务字段对应:**
| message_type | 必填字段 |
|--------------|----------|
| friend_request | friendship_id |
| calendar | schedule_item_id |
| system | 全部可空 |
| group | group_id |
---
### todos
待办事项。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `owner_id` | UUID | 所有者 ID |
| `title` | VARCHAR(255) | 标题 |
| `description` | TEXT | 描述 |
| `due_at` | TIMESTAMPTZ | 截止时间 |
| `priority` | INTEGER | 优先级(1=重要且紧急, 2=重要不紧急, 3=紧急不重要, 4=不重要不紧急) |
| `status` | VARCHAR(20) | 状态:`pending`, `done`, `canceled` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `completed_at` | TIMESTAMPTZ | 完成时间 |
---
### todo_sources
待办与日程来源关联。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `todo_id` | UUID | 待办 ID |
| `schedule_item_id` | UUID | 日程事项 ID |
| `created_at` | TIMESTAMPTZ | 创建时间 |
**约束:** `(todo_id, schedule_item_id)` 唯一
---
### automation_jobs
自动化定时任务。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `owner_id` | UUID | 所有者 ID |
| `title` | VARCHAR(255) | 任务标题 |
| `prompt` | TEXT | AI 执行 prompt |
| `schedule_type` | VARCHAR(20) | 调度类型:`daily`, `weekly` |
| `run_at` | TIMESTAMPTZ | 首次运行时间 |
| `next_run_at` | TIMESTAMPTZ | 下次运行时间 |
| `timezone` | VARCHAR(50) | 时区 |
| `last_run_at` | TIMESTAMPTZ | 最近运行时间 |
| `status` | VARCHAR(20) | 状态:`active`, `disabled` |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
---
### sessions
Agent 对话会话。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `user_id` | UUID | 用户 ID |
| `session_type` | VARCHAR(20) | 会话类型:`chat`, `automation` |
| `job_id` | UUID | 自动化任务 IDautomation 时必填) |
| `last_activity_at` | TIMESTAMPTZ | 最后活跃时间 |
| `created_at` | TIMESTAMPTZ | 创建时间 |
**约束:** `session_type='chat' → job_id IS NULL`, `session_type='automation' → job_id IS NOT NULL`
---
### llm_factories
LLM 工厂配置。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `name` | VARCHAR(50) | 工厂名称 |
| `base_url` | TEXT | API 基础 URL |
| `api_key` | TEXT | API 密钥 |
| `enabled` | BOOLEAN | 是否启用 |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
---
### llms
LLM 模型实例。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | UUID | PK |
| `factory_id` | UUID | 工厂 ID |
| `model_id` | VARCHAR(50) | 模型标识 |
| `name` | VARCHAR(100) | 显示名称 |
| `context_window` | INTEGER | 上下文窗口大小 |
| `enabled` | BOOLEAN | 是否启用 |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |
---
## 外键删除策略
| 外键 | 删除策略 |
|------|----------|
| `sessions.job_id` | RESTRICT |
| `todo_sources.todo_id` | CASCADE |
| `todo_sources.schedule_item_id` | CASCADE |
| `inbox_messages.friendship_id` | CASCADE |
| `inbox_messages.schedule_item_id` | CASCADE |
| `inbox_messages.group_id` | CASCADE |
---
## RLS 策略
所有 `public` 业务表默认启用 RLS
- `anon`: 全部 DENY
- `authenticated`: 全部 DENY
- `service_role`: 由后端服务连接,不依赖 RLS