refactor(chat): 重构聊天模块并集成历史消息加载功能
- 删除冗余的 chat_history_repository 和 home_mock_data - 简化 ag_ui_event fromJson 使用工厂映射表 - 提取 ChatBloc 事件处理方法,添加 loadHistory/loadMoreHistory - HomeScreen 集成 ChatBloc 实现历史消息加载和下拉刷新 - 更新 AGENTS.md 文档约束
This commit is contained in:
@@ -37,6 +37,7 @@ Follow this hierarchy when developing:
|
||||
- Default branch: `dev`
|
||||
- Feature development: use worktree `git worktree add -b feature/xxx ../feature-xxx dev`
|
||||
- Never develop directly on `main`
|
||||
- **Never push to remote unless explicitly requested by user**
|
||||
|
||||
## Supabase Services
|
||||
|
||||
|
||||
+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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,3 +206,7 @@ class AgentType(str, Enum):
|
||||
- [ ] Policy target roles are explicit (`anon`, `authenticated`, or both)
|
||||
- [ ] Downgrade path is reversible and does not silently weaken intended production security
|
||||
- [ ] 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`
|
||||
|
||||
@@ -33,3 +33,42 @@
|
||||
- `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 页面
|
||||
|
||||
@@ -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 ID(work 类型必填) |
|
||||
| `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_id,user 类型必须无 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 | 自动化任务 ID(automation 时必填) |
|
||||
| `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
|
||||
Reference in New Issue
Block a user