feat: 优化前端 UI 组件与交互体验

- 优化日历、待办、消息等页面交互
- 更新 ChatBloc 与 UI Schema 渲染
- 优化联系人、首页、设置页面体验
This commit is contained in:
qzl
2026-03-16 16:11:28 +08:00
parent a75c868bca
commit 4b92772535
18 changed files with 1591 additions and 1780 deletions
@@ -1,26 +1,15 @@
import 'dart:convert';
import 'package:json_annotation/json_annotation.dart';
import 'tool_result.dart';
part 'ag_ui_event.g.dart';
class AgUiEventTypeWire {
static const runStarted = 'RUN_STARTED';
static const runFinished = 'RUN_FINISHED';
static const runError = 'RUN_ERROR';
static const stepStarted = 'STEP_STARTED';
static const stepFinished = 'STEP_FINISHED';
static const textMessageStart = 'TEXT_MESSAGE_START';
static const textMessageContent = 'TEXT_MESSAGE_CONTENT';
static const textMessageEnd = 'TEXT_MESSAGE_END';
static const toolCallStart = 'TOOL_CALL_START';
static const toolCallArgs = 'TOOL_CALL_ARGS';
static const toolCallEnd = 'TOOL_CALL_END';
static const toolCallResult = 'TOOL_CALL_RESULT';
static const toolCallError = 'TOOL_CALL_ERROR';
static const stateSnapshot = 'STATE_SNAPSHOT';
static const messagesSnapshot = 'MESSAGES_SNAPSHOT';
}
enum AgUiEventType {
@@ -29,55 +18,41 @@ enum AgUiEventType {
runError,
stepStarted,
stepFinished,
textMessageStart,
textMessageContent,
textMessageEnd,
toolCallStart,
toolCallArgs,
toolCallEnd,
toolCallResult,
toolCallError,
stateSnapshot,
messagesSnapshot,
unknown,
}
// wire 类型到枚举的映射
const _wireToTypeMap = {
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
AgUiEventTypeWire.runError: AgUiEventType.runError,
AgUiEventTypeWire.stepStarted: AgUiEventType.stepStarted,
AgUiEventTypeWire.stepFinished: AgUiEventType.stepFinished,
AgUiEventTypeWire.textMessageStart: AgUiEventType.textMessageStart,
AgUiEventTypeWire.textMessageContent: AgUiEventType.textMessageContent,
AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd,
AgUiEventTypeWire.toolCallStart: AgUiEventType.toolCallStart,
AgUiEventTypeWire.toolCallArgs: AgUiEventType.toolCallArgs,
AgUiEventTypeWire.toolCallEnd: AgUiEventType.toolCallEnd,
AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult,
AgUiEventTypeWire.toolCallError: AgUiEventType.toolCallError,
AgUiEventTypeWire.stateSnapshot: AgUiEventType.stateSnapshot,
AgUiEventTypeWire.messagesSnapshot: AgUiEventType.messagesSnapshot,
};
// 枚举到 wire 类型的映射
const _typeToWireMap = {
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
AgUiEventType.runError: AgUiEventTypeWire.runError,
AgUiEventType.stepStarted: AgUiEventTypeWire.stepStarted,
AgUiEventType.stepFinished: AgUiEventTypeWire.stepFinished,
AgUiEventType.textMessageStart: AgUiEventTypeWire.textMessageStart,
AgUiEventType.textMessageContent: AgUiEventTypeWire.textMessageContent,
AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd,
AgUiEventType.toolCallStart: AgUiEventTypeWire.toolCallStart,
AgUiEventType.toolCallArgs: AgUiEventTypeWire.toolCallArgs,
AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd,
AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult,
AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError,
AgUiEventType.stateSnapshot: AgUiEventTypeWire.stateSnapshot,
AgUiEventType.messagesSnapshot: AgUiEventTypeWire.messagesSnapshot,
AgUiEventType.unknown: '',
};
@@ -86,383 +61,310 @@ 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.stepStarted: StepStartedEvent.fromJson,
AgUiEventType.stepFinished: StepFinishedEvent.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.stateSnapshot: StateSnapshotEvent.fromJson,
AgUiEventType.messagesSnapshot: MessagesSnapshotEvent.fromJson,
AgUiEventType.unknown: UnknownAgUiEvent.fromJson,
};
abstract class AgUiEvent {
const AgUiEvent({required this.type});
@JsonSerializable(createFactory: false)
class AgUiEvent {
final AgUiEventType type;
AgUiEvent({required this.type});
factory AgUiEvent.fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String? ?? '';
final type = agUiEventTypeFromWire(typeStr);
return _typeToFactory[type]?.call(json) ?? UnknownAgUiEvent.fromJson(json);
final wireType = json['type'];
final type = wireType is String
? agUiEventTypeFromWire(wireType)
: AgUiEventType.unknown;
return switch (type) {
AgUiEventType.runStarted => RunStartedEvent.fromJson(json),
AgUiEventType.runFinished => RunFinishedEvent.fromJson(json),
AgUiEventType.runError => RunErrorEvent.fromJson(json),
AgUiEventType.stepStarted => StepStartedEvent.fromJson(json),
AgUiEventType.stepFinished => StepFinishedEvent.fromJson(json),
AgUiEventType.textMessageEnd => TextMessageEndEvent.fromJson(json),
AgUiEventType.toolCallStart => ToolCallStartEvent.fromJson(json),
AgUiEventType.toolCallArgs => ToolCallArgsEvent.fromJson(json),
AgUiEventType.toolCallEnd => ToolCallEndEvent.fromJson(json),
AgUiEventType.toolCallResult => ToolCallResultEvent.fromJson(json),
AgUiEventType.toolCallError => ToolCallErrorEvent.fromJson(json),
AgUiEventType.unknown => UnknownAgUiEvent(rawJson: json),
};
}
Map<String, dynamic> toJson() => _$AgUiEventToJson(this);
}
@JsonSerializable(createFactory: false, createToJson: false)
class UnknownAgUiEvent extends AgUiEvent {
final Map<String, dynamic> rawJson;
UnknownAgUiEvent({required this.rawJson})
const UnknownAgUiEvent({required this.rawJson})
: super(type: AgUiEventType.unknown);
factory UnknownAgUiEvent.fromJson(Map<String, dynamic> json) =>
UnknownAgUiEvent(rawJson: json);
@override
Map<String, dynamic> toJson() => rawJson;
final Map<String, dynamic> rawJson;
}
@JsonSerializable()
class RunStartedEvent extends AgUiEvent {
final String threadId;
final String runId;
RunStartedEvent({required this.threadId, required this.runId})
: super(type: AgUiEventType.runStarted);
factory RunStartedEvent.fromJson(Map<String, dynamic> json) =>
_$RunStartedEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$RunStartedEventToJson(this);
}
@JsonSerializable()
class RunFinishedEvent extends AgUiEvent {
final String threadId;
final String runId;
factory RunStartedEvent.fromJson(Map<String, dynamic> json) =>
RunStartedEvent(
threadId: _asString(json['threadId']),
runId: _asString(json['runId']),
);
}
class RunFinishedEvent extends AgUiEvent {
RunFinishedEvent({required this.threadId, required this.runId})
: super(type: AgUiEventType.runFinished);
factory RunFinishedEvent.fromJson(Map<String, dynamic> json) =>
_$RunFinishedEventFromJson(json);
final String threadId;
final String runId;
@override
Map<String, dynamic> toJson() => _$RunFinishedEventToJson(this);
factory RunFinishedEvent.fromJson(Map<String, dynamic> json) =>
RunFinishedEvent(
threadId: _asString(json['threadId']),
runId: _asString(json['runId']),
);
}
@JsonSerializable()
class RunErrorEvent extends AgUiEvent {
final String message;
final String? code;
RunErrorEvent({required this.message, this.code})
: super(type: AgUiEventType.runError);
factory RunErrorEvent.fromJson(Map<String, dynamic> json) =>
_$RunErrorEventFromJson(json);
final String message;
final String? code;
@override
Map<String, dynamic> toJson() => _$RunErrorEventToJson(this);
factory RunErrorEvent.fromJson(Map<String, dynamic> json) => RunErrorEvent(
message: _asString(json['message'], fallback: 'Unknown error'),
code: json['code'] as String?,
);
}
@JsonSerializable()
class StepStartedEvent extends AgUiEvent {
final String stepName;
StepStartedEvent({required this.stepName})
: super(type: AgUiEventType.stepStarted);
factory StepStartedEvent.fromJson(Map<String, dynamic> json) =>
_$StepStartedEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$StepStartedEventToJson(this);
}
@JsonSerializable()
class StepFinishedEvent extends AgUiEvent {
final String stepName;
factory StepStartedEvent.fromJson(Map<String, dynamic> json) =>
StepStartedEvent(stepName: _asString(json['stepName']));
}
class StepFinishedEvent extends AgUiEvent {
StepFinishedEvent({required this.stepName})
: super(type: AgUiEventType.stepFinished);
final String stepName;
factory StepFinishedEvent.fromJson(Map<String, dynamic> json) =>
_$StepFinishedEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$StepFinishedEventToJson(this);
StepFinishedEvent(stepName: _asString(json['stepName']));
}
@JsonSerializable()
class TextMessageStartEvent extends AgUiEvent {
final String messageId;
final String role;
TextMessageStartEvent({required this.messageId, required this.role})
: super(type: AgUiEventType.textMessageStart);
factory TextMessageStartEvent.fromJson(Map<String, dynamic> json) =>
_$TextMessageStartEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$TextMessageStartEventToJson(this);
}
@JsonSerializable()
class TextMessageContentEvent extends AgUiEvent {
final String messageId;
final String delta;
TextMessageContentEvent({required this.messageId, required this.delta})
: super(type: AgUiEventType.textMessageContent);
factory TextMessageContentEvent.fromJson(Map<String, dynamic> json) =>
_$TextMessageContentEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$TextMessageContentEventToJson(this);
}
@JsonSerializable()
class TextMessageEndEvent extends AgUiEvent {
final String messageId;
TextMessageEndEvent({
required this.messageId,
required this.answer,
required this.role,
required this.status,
required this.uiSchema,
}) : super(type: AgUiEventType.textMessageEnd);
TextMessageEndEvent({required this.messageId})
: super(type: AgUiEventType.textMessageEnd);
final String messageId;
final String answer;
final String role;
final String status;
final Map<String, dynamic>? uiSchema;
factory TextMessageEndEvent.fromJson(Map<String, dynamic> json) =>
_$TextMessageEndEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$TextMessageEndEventToJson(this);
TextMessageEndEvent(
messageId: _asString(json['messageId']),
answer: _asString(json['answer']),
role: _asString(json['role'], fallback: 'assistant'),
status: _asString(json['status'], fallback: 'success'),
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
);
}
@JsonSerializable()
class ToolCallStartEvent extends AgUiEvent {
ToolCallStartEvent({required this.toolCallId, required this.toolCallName})
: super(type: AgUiEventType.toolCallStart);
final String toolCallId;
final String toolCallName;
final String? parentMessageId;
ToolCallStartEvent({
required this.toolCallId,
required this.toolCallName,
this.parentMessageId,
}) : super(type: AgUiEventType.toolCallStart);
factory ToolCallStartEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallStartEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$ToolCallStartEventToJson(this);
ToolCallStartEvent(
toolCallId: _asString(json['toolCallId']),
toolCallName: _asString(json['toolCallName']),
);
}
@JsonSerializable()
class ToolCallArgsEvent extends AgUiEvent {
final String toolCallId;
final String delta;
ToolCallArgsEvent({required this.toolCallId, required this.delta})
ToolCallArgsEvent({required this.toolCallId, required this.args})
: super(type: AgUiEventType.toolCallArgs);
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallArgsEventFromJson(json);
final String toolCallId;
final Map<String, dynamic> args;
@override
Map<String, dynamic> toJson() => _$ToolCallArgsEventToJson(this);
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> json) =>
ToolCallArgsEvent(
toolCallId: _asString(json['toolCallId']),
args: _asMap(json['args']) ?? const {},
);
}
@JsonSerializable()
class ToolCallEndEvent extends AgUiEvent {
final String toolCallId;
ToolCallEndEvent({required this.toolCallId})
: super(type: AgUiEventType.toolCallEnd);
factory ToolCallEndEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallEndEventFromJson(json);
final String toolCallId;
@override
Map<String, dynamic> toJson() => _$ToolCallEndEventToJson(this);
factory ToolCallEndEvent.fromJson(Map<String, dynamic> json) =>
ToolCallEndEvent(toolCallId: _asString(json['toolCallId']));
}
@JsonSerializable(createFactory: false, createToJson: false)
class ToolCallResultEvent extends AgUiEvent {
final String messageId;
final String toolCallId;
final String content;
ToolCallResultEvent({
required this.messageId,
required this.toolCallId,
required this.content,
required this.toolName,
required this.resultSummary,
required this.status,
required this.uiSchema,
}) : super(type: AgUiEventType.toolCallResult);
Map<String, dynamic> get payload {
try {
final decoded = jsonDecode(content);
if (decoded is Map<String, dynamic>) {
return decoded;
}
} catch (_) {}
return {'content': content};
}
final String messageId;
final String toolCallId;
final String toolName;
final String resultSummary;
final String status;
final Map<String, dynamic>? uiSchema;
Map<String, dynamic> get result {
final rawResult = payload['result'];
if (rawResult is Map<String, dynamic>) {
return rawResult;
}
return payload;
}
UiCard? get ui {
final rawUi = payload['ui'];
if (rawUi is Map<String, dynamic>) {
return UiCard.fromJson(rawUi);
}
final rawResult = payload['result'];
if (rawResult is Map<String, dynamic>) {
final type = rawResult['type'];
final data = rawResult['data'];
if (type is String && data is Map<String, dynamic>) {
return UiCard.fromJson(rawResult);
}
}
return null;
}
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) {
final rawContent = json['content'];
final hasStructuredFields =
json['ui'] != null || json['result'] != null || json['error'] != null;
final content = switch (rawContent) {
String value when value.trim().startsWith('{') => value,
String value when value.trim().startsWith('[') => value,
String value when hasStructuredFields => jsonEncode({
'toolName': json['toolName'],
'result': json['result'],
'error': json['error'],
'ui': json['ui'],
'content': value,
}),
String value => value,
_ => jsonEncode({
'toolName': json['toolName'],
'result': json['result'],
'error': json['error'],
'ui': json['ui'],
'content': json['content'],
}),
};
final toolCallId =
json['toolCallId'] as String? ?? json['callId'] as String? ?? '';
final messageId = json['messageId'] as String? ?? 'tool-result-$toolCallId';
return ToolCallResultEvent(
messageId: messageId,
toolCallId: toolCallId,
content: content,
);
}
@override
Map<String, dynamic> toJson() => {
'type': agUiEventTypeToWire(type),
'messageId': messageId,
'toolCallId': toolCallId,
'content': content,
};
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
ToolCallResultEvent(
messageId: _asString(
json['messageId'],
fallback: 'tool-${_asString(json['tool_call_id'])}',
),
toolCallId: _asString(json['tool_call_id'] ?? json['toolCallId']),
toolName: _asString(json['tool_name'] ?? json['toolName']),
resultSummary: _asString(
json['result_summary'] ?? json['resultSummary'],
),
status: _asString(json['status'], fallback: 'success'),
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
);
}
@JsonSerializable()
class ToolCallErrorEvent extends AgUiEvent {
ToolCallErrorEvent({required this.toolCallId, required this.error, this.code})
: super(type: AgUiEventType.toolCallError);
final String toolCallId;
final String error;
final String? code;
ToolCallErrorEvent({required this.toolCallId, required this.error, this.code})
: super(type: AgUiEventType.toolCallError);
factory ToolCallErrorEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallErrorEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$ToolCallErrorEventToJson(this);
ToolCallErrorEvent(
toolCallId: _asString(json['toolCallId']),
error: _asString(json['error'], fallback: 'Tool call failed'),
code: json['code'] as String?,
);
}
@JsonSerializable(createFactory: false, createToJson: false)
class StateSnapshotEvent extends AgUiEvent {
final Map<String, dynamic> snapshot;
StateSnapshotEvent({required this.snapshot})
: super(type: AgUiEventType.stateSnapshot);
factory StateSnapshotEvent.fromJson(Map<String, dynamic> json) {
final rawSnapshot = json['snapshot'];
return StateSnapshotEvent(
snapshot: rawSnapshot is Map<String, dynamic>
? rawSnapshot
: <String, dynamic>{},
);
}
@override
Map<String, dynamic> toJson() => {
'type': agUiEventTypeToWire(type),
'snapshot': snapshot,
};
}
@JsonSerializable()
class MessagesSnapshotEvent extends AgUiEvent {
final List<SnapshotMessage> messages;
MessagesSnapshotEvent({required this.messages})
: super(type: AgUiEventType.messagesSnapshot);
factory MessagesSnapshotEvent.fromJson(Map<String, dynamic> json) =>
_$MessagesSnapshotEventFromJson(json);
@override
Map<String, dynamic> toJson() => _$MessagesSnapshotEventToJson(this);
}
@JsonSerializable()
class SnapshotMessage {
final String id;
final String role;
final String? content;
final String? toolCallId;
final UiCard? ui;
final DateTime? timestamp;
final List<Map<String, dynamic>>? attachments;
SnapshotMessage({
required this.id,
required this.role,
this.content,
this.toolCallId,
this.ui,
this.timestamp,
this.attachments,
class HistorySnapshot {
const HistorySnapshot({
required this.scope,
required this.threadId,
required this.day,
required this.hasMore,
required this.messages,
});
factory SnapshotMessage.fromJson(Map<String, dynamic> json) =>
_$SnapshotMessageFromJson(json);
final String scope;
final String? threadId;
final String? day;
final bool hasMore;
final List<HistoryMessage> messages;
Map<String, dynamic> toJson() => _$SnapshotMessageToJson(this);
factory HistorySnapshot.fromJson(Map<String, dynamic> json) {
final rawMessages = json['messages'];
final messages = rawMessages is List
? rawMessages
.whereType<Map<String, dynamic>>()
.map(HistoryMessage.fromJson)
.toList()
: const <HistoryMessage>[];
return HistorySnapshot(
scope: _asString(json['scope'], fallback: 'history_day'),
threadId: json['threadId'] as String?,
day: json['day'] as String?,
hasMore: json['hasMore'] == true,
messages: messages,
);
}
}
class HistoryMessage {
const HistoryMessage({
required this.id,
required this.seq,
required this.role,
required this.content,
required this.timestamp,
this.url,
this.uiSchema,
});
final String id;
final int seq;
final String role;
final String content;
final DateTime timestamp;
final String? url;
final Map<String, dynamic>? uiSchema;
factory HistoryMessage.fromJson(Map<String, dynamic> json) => HistoryMessage(
id: _asString(json['id']),
seq: _asInt(json['seq']),
role: _asString(json['role']),
content: _asString(json['content']),
timestamp:
DateTime.tryParse(_asString(json['timestamp'])) ?? DateTime.now(),
url: json['url'] as String?,
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
);
}
String _asString(Object? value, {String fallback = ''}) {
if (value is String) {
return value;
}
return fallback;
}
int _asInt(Object? value) {
if (value is int) {
return value;
}
if (value is double) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value) ?? 0;
}
return 0;
}
Map<String, dynamic>? _asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
final result = <String, dynamic>{};
for (final entry in value.entries) {
final key = entry.key;
if (key is String) {
result[key] = entry.value;
}
}
return result;
}
return null;
}
@@ -1,5 +1,3 @@
import 'tool_result.dart';
enum ChatItemType { message, toolCall, toolResult }
enum MessageSender { user, ai }
@@ -105,7 +103,7 @@ class ToolResultItem extends ChatListItem {
@override
final String id;
final String callId;
final UiCard uiCard;
final Map<String, dynamic> uiSchema;
@override
final DateTime timestamp;
@override
@@ -114,7 +112,7 @@ class ToolResultItem extends ChatListItem {
ToolResultItem({
required this.id,
required this.callId,
required this.uiCard,
required this.uiSchema,
required this.timestamp,
required this.sender,
});
@@ -7,20 +7,15 @@ import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/i_api_client.dart';
import '../ai/ai_decision_engine.dart';
import '../models/ag_ui_event.dart';
import '../tools/tool_registry.dart';
typedef EventCallback = void Function(AgUiEvent event);
const _runIdPrefix = 'run_';
const _messageIdPrefix = 'msg_';
const _toolCallIdPrefix = 'tc_';
class AgUiService {
final IApiClient _apiClient;
EventCallback onEvent;
final AiDecisionEngine _decisionEngine;
final Map<String, String> _lastEventIdByThread = {};
int _activeStreamToken = 0;
@@ -29,8 +24,7 @@ class AgUiService {
AgUiService({EventCallback? onEvent, required IApiClient apiClient})
: onEvent = onEvent ?? ((_) {}),
_apiClient = apiClient,
_decisionEngine = AiDecisionEngine();
_apiClient = apiClient;
Future<void> sendMessage(String content, {List<XFile>? images}) async {
final streamToken = ++_activeStreamToken;
@@ -51,23 +45,19 @@ class AgUiService {
await _streamEventsFromApi(threadId, streamToken: streamToken);
}
Future<void> loadHistory({DateTime? beforeDate}) async {
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
final path = _buildHistoryPath(beforeDate: beforeDate);
final response = await _apiClient.get<Map<String, dynamic>>(path);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/history response');
}
final event = AgUiEvent.fromJson(payload);
if (event is StateSnapshotEvent) {
final snapshot = event.snapshot;
final threadIdFromSnapshot = snapshot['threadId'] as String?;
if (threadIdFromSnapshot != null && threadIdFromSnapshot.isNotEmpty) {
_threadId = threadIdFromSnapshot;
}
_hasMoreHistory = snapshot['hasMore'] == true;
final snapshot = HistorySnapshot.fromJson(payload);
if (snapshot.threadId != null && snapshot.threadId!.isNotEmpty) {
_threadId = snapshot.threadId;
}
onEvent(event);
_hasMoreHistory = snapshot.hasMore;
return snapshot;
}
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
@@ -105,60 +95,6 @@ class AgUiService {
return transcript;
}
Future<void> approveToolCall({
required String toolCallId,
required String toolName,
required Map<String, dynamic> args,
}) async {
final streamToken = ++_activeStreamToken;
final threadId = _threadId;
if (threadId == null || threadId.isEmpty) {
throw StateError('Missing threadId for resume');
}
ToolRegistry.initialize();
final nonce = args['__nonce'];
if (nonce is! String || nonce.isEmpty) {
throw StateError('Missing tool nonce for resume');
}
final localResult = await ToolRegistry.execute(toolName, args);
if (localResult['ok'] != true) {
throw StateError('Frontend tool execution failed');
}
final runInput = {
'threadId': threadId,
'runId': _nextId(_runIdPrefix),
'state': <String, dynamic>{},
'messages': [
{
'id': _nextId('tool_'),
'role': 'tool',
'toolCallId': toolCallId,
'content': jsonEncode({
'toolName': toolName,
'toolArgs': args,
'nonce': nonce,
'result': localResult,
}),
},
],
'tools': _buildTools(),
'context': <Map<String, dynamic>>[],
'forwardedProps': <String, dynamic>{},
};
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs/$threadId/resume',
data: runInput,
);
final payload = response.data;
if (payload is Map<String, dynamic>) {
final responseThreadId = payload['threadId'];
if (responseThreadId is String && responseThreadId.isNotEmpty) {
_threadId = responseThreadId;
}
}
await _streamEventsFromApi(threadId, streamToken: streamToken);
}
bool hasEarlierHistory(DateTime fromDate) {
// 历史是否还有更多由后端 history snapshot 的 hasMore 驱动。
// 参数保留是为了兼容 ChatBloc 现有调用签名。
@@ -199,9 +135,6 @@ class AgUiService {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
final event = AgUiEvent.fromJson(decoded);
if (event is StateSnapshotEvent) {
_hasMoreHistory = event.snapshot['hasMore'] == true;
}
onEvent(event);
}
} catch (_) {
@@ -285,7 +218,7 @@ class AgUiService {
'messages': [
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
],
'tools': _buildTools(),
'tools': <Map<String, dynamic>>[],
'context': <Map<String, dynamic>>[],
'forwardedProps': <String, dynamic>{},
};
@@ -343,26 +276,6 @@ class AgUiService {
return attachments;
}
List<Map<String, dynamic>> _buildTools() {
return [
{
'name': 'front.navigate_to_route',
'description': 'Navigate user to a route in the mobile app.',
'parameters': {
'type': 'object',
'properties': {
'target': {'type': 'string', 'description': 'Route path target'},
'replace': {
'type': 'boolean',
'description': 'Use replace navigation',
},
},
'required': ['target'],
},
},
];
}
String _buildHistoryPath({DateTime? beforeDate}) {
final query = <String>[];
if (_threadId != null && _threadId!.isNotEmpty) {
@@ -1,10 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/di/injection.dart';
import '../../data/models/ag_ui_event.dart';
import '../../data/models/chat_list_item.dart';
@@ -84,18 +82,17 @@ class ChatState {
}
class ChatBloc extends Cubit<ChatState> {
final AgUiService _service;
final Map<String, String> _toolCallArgsBuffer = {};
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
<String, Future<Uint8List?>>{};
ChatBloc({AgUiService? service, required IApiClient apiClient})
: _service = service ?? AgUiService(apiClient: apiClient),
super(const ChatState()) {
_service.onEvent = _handleEvent;
}
final AgUiService _service;
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
<String, Future<Uint8List?>>{};
void _handleEvent(AgUiEvent event) {
switch (event.type) {
case AgUiEventType.runStarted:
@@ -136,10 +133,6 @@ class ChatBloc extends Cubit<ChatState> {
_handleStepStarted(event as StepStartedEvent);
case AgUiEventType.stepFinished:
_handleStepFinished(event as StepFinishedEvent);
case AgUiEventType.textMessageStart:
_handleTextMessageStart(event as TextMessageStartEvent);
case AgUiEventType.textMessageContent:
_handleTextMessageContent(event as TextMessageContentEvent);
case AgUiEventType.textMessageEnd:
_handleTextMessageEnd(event as TextMessageEndEvent);
case AgUiEventType.toolCallStart:
@@ -152,10 +145,6 @@ class ChatBloc extends Cubit<ChatState> {
_handleToolCallResult(event as ToolCallResultEvent);
case AgUiEventType.toolCallError:
_handleToolCallError(event as ToolCallErrorEvent);
case AgUiEventType.stateSnapshot:
_handleStateSnapshot(event as StateSnapshotEvent);
case AgUiEventType.messagesSnapshot:
_handleMessagesSnapshot(event as MessagesSnapshotEvent);
case AgUiEventType.unknown:
break;
}
@@ -171,213 +160,179 @@ class ChatBloc extends Cubit<ChatState> {
}
}
void _handleTextMessageStart(TextMessageStartEvent startEvent) {
final newMessage = TextMessageItem(
id: startEvent.messageId,
content: '',
timestamp: DateTime.now(),
sender: MessageSender.ai,
isStreaming: true,
void _handleTextMessageEnd(TextMessageEndEvent event) {
final timestamp = DateTime.now();
final items = List<ChatListItem>.from(state.items);
final messageIndex = items.indexWhere(
(item) => item.id == event.messageId && item is TextMessageItem,
);
if (messageIndex >= 0) {
final existing = items[messageIndex] as TextMessageItem;
items[messageIndex] = existing.copyWith(
content: event.answer,
isStreaming: false,
);
} else {
items.add(
TextMessageItem(
id: event.messageId,
content: event.answer,
timestamp: timestamp,
sender: MessageSender.ai,
isStreaming: false,
),
);
}
final uiSchema = event.uiSchema;
if (uiSchema != null) {
final uiItemId = '${event.messageId}-ui';
final existingUiIndex = items.indexWhere((item) => item.id == uiItemId);
final uiItem = ToolResultItem(
id: uiItemId,
callId: event.messageId,
uiSchema: uiSchema,
timestamp: timestamp,
sender: MessageSender.ai,
);
if (existingUiIndex >= 0) {
items[existingUiIndex] = uiItem;
} else {
items.add(uiItem);
}
}
emit(
state.copyWith(
items: [...state.items, newMessage],
currentMessageId: startEvent.messageId,
isWaitingFirstToken: false,
isStreaming: true,
),
);
}
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,
items: items,
currentMessageId: null,
isWaitingFirstToken: false,
isStreaming: false,
),
);
}
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 _handleToolCallStart(ToolCallStartEvent event) {
final items = List<ChatListItem>.from(state.items)
..add(
ToolCallItem(
id: event.toolCallId,
callId: event.toolCallId,
toolName: event.toolCallName,
args: const {},
status: ToolCallStatus.pending,
timestamp: DateTime.now(),
sender: MessageSender.ai,
),
);
emit(state.copyWith(items: items));
}
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) {
final nextStatus = item.toolName == 'front.navigate_to_route'
? ToolCallStatus.pending
: ToolCallStatus.executing;
return item.copyWith(args: parsedArgs, status: nextStatus);
void _handleToolCallArgs(ToolCallArgsEvent event) {
final items = state.items.map((item) {
if (item is ToolCallItem && item.id == event.toolCallId) {
return item.copyWith(args: event.args);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems));
emit(state.copyWith(items: items));
}
void _handleToolCallResult(ToolCallResultEvent resultEvent) {
final filteredItems = state.items.where((item) {
if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
return false;
void _handleToolCallEnd(ToolCallEndEvent event) {
final items = state.items.map((item) {
if (item is ToolCallItem && item.id == event.toolCallId) {
return item.copyWith(status: ToolCallStatus.executing);
}
return true;
return item;
}).toList();
final uiCard = resultEvent.ui;
if (uiCard == null) {
emit(state.copyWith(items: filteredItems));
return;
}
final resultItem = ToolResultItem(
id: resultEvent.messageId,
callId: resultEvent.toolCallId,
uiCard: uiCard,
timestamp: DateTime.now(),
sender: MessageSender.ai,
);
emit(state.copyWith(items: [...filteredItems, resultItem]));
emit(state.copyWith(items: items));
}
void _handleToolCallError(ToolCallErrorEvent errorEvent) {
_toolCallArgsBuffer.remove(errorEvent.toolCallId);
final updatedItems = state.items.map((item) {
if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
void _handleToolCallResult(ToolCallResultEvent event) {
final timestamp = DateTime.now();
final items = state.items.where((item) {
return !(item is ToolCallItem && item.id == event.toolCallId);
}).toList();
if (event.uiSchema != null) {
_upsertById(
items,
ToolResultItem(
id: event.messageId,
callId: event.toolCallId,
uiSchema: event.uiSchema!,
timestamp: timestamp,
sender: MessageSender.ai,
),
);
} else if (event.resultSummary.isNotEmpty) {
_upsertById(
items,
TextMessageItem(
id: event.messageId,
content: event.resultSummary,
timestamp: timestamp,
sender: MessageSender.ai,
),
);
}
emit(state.copyWith(items: items));
}
void _handleToolCallError(ToolCallErrorEvent event) {
final items = state.items.map((item) {
if (item is ToolCallItem && item.id == event.toolCallId) {
return item.copyWith(
status: ToolCallStatus.error,
errorMessage: errorEvent.error,
errorMessage: event.error,
);
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems));
emit(state.copyWith(items: items));
}
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);
List<ChatListItem> _convertHistoryMessages(List<HistoryMessage> messages) {
final converted = <ChatListItem>[];
for (final msg in messages) {
final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai;
final attachments = <Map<String, dynamic>>[];
if (msg.url != null && msg.url!.isNotEmpty) {
attachments.add({'url': msg.url!, 'mimeType': 'image/*'});
}
} else if (newOldestDate != null) {
newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate);
}
emit(
state.copyWith(
items: allItems,
oldestLoadedDate: newOldestDate,
hasEarlierHistory: newHasEarlierHistory,
),
);
}
void _handleStateSnapshot(StateSnapshotEvent stateSnapshotEvent) {
final snapshot = stateSnapshotEvent.snapshot;
if (snapshot['scope'] != 'history_day') {
return;
}
final rawMessages = snapshot['messages'];
if (rawMessages is! List<dynamic>) {
_handleMessagesSnapshot(MessagesSnapshotEvent(messages: const []));
return;
}
final parsed = <SnapshotMessage>[];
for (final raw in rawMessages) {
if (raw is! Map<String, dynamic>) {
continue;
if (msg.content.isNotEmpty || sender == MessageSender.user) {
converted.add(
TextMessageItem(
id: msg.id,
content: msg.content,
timestamp: msg.timestamp,
sender: sender,
attachments: attachments,
),
);
}
parsed.add(SnapshotMessage.fromJson(raw));
}
_handleMessagesSnapshot(MessagesSnapshotEvent(messages: parsed));
}
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,
attachments: msg.attachments ?? const [],
);
case 'assistant':
return TextMessageItem(
id: msg.id,
content: msg.content ?? '',
timestamp: timestamp,
if (msg.uiSchema != null) {
converted.add(
ToolResultItem(
id: '${msg.id}-ui',
callId: msg.id,
uiSchema: msg.uiSchema!,
timestamp: msg.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();
}
return converted;
}
DateTime? _extractDateFromItems(List<ChatListItem> items) {
if (items.isEmpty) return null;
return items
.map(
(item) => DateTime(
@@ -393,8 +348,8 @@ class ChatBloc extends Cubit<ChatState> {
final attachments = (images ?? const <XFile>[])
.map(
(image) => <String, dynamic>{
"path": image.path,
"mimeType": "image/*",
'path': image.path,
'mimeType': 'image/*',
},
)
.toList();
@@ -434,7 +389,16 @@ class ChatBloc extends Cubit<ChatState> {
if (state.isLoadingHistory) return;
emit(state.copyWith(isLoadingHistory: true));
try {
await _service.loadHistory();
final snapshot = await _service.loadHistory();
final newItems = _convertHistoryMessages(snapshot.messages);
final oldestDate = _extractDateFromItems(newItems);
emit(
state.copyWith(
items: newItems,
oldestLoadedDate: oldestDate,
hasEarlierHistory: snapshot.hasMore,
),
);
} finally {
emit(state.copyWith(isLoadingHistory: false));
}
@@ -445,69 +409,38 @@ class ChatBloc extends Cubit<ChatState> {
if (state.oldestLoadedDate == null) return;
emit(state.copyWith(isLoadingHistory: true));
try {
await _service.loadHistory(beforeDate: state.oldestLoadedDate);
final snapshot = await _service.loadHistory(
beforeDate: state.oldestLoadedDate,
);
final newItems = _convertHistoryMessages(snapshot.messages);
final mergedById = <String, ChatListItem>{
for (final item in state.items) item.id: item,
};
for (final item in newItems) {
mergedById[item.id] = item;
}
final merged = mergedById.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final oldestDate = _extractDateFromItems(merged);
emit(
state.copyWith(
items: merged,
oldestLoadedDate: oldestDate,
hasEarlierHistory: snapshot.hasMore,
),
);
} finally {
emit(state.copyWith(isLoadingHistory: false));
}
}
Future<void> approveToolCall(String toolCallId) async {
ToolCallItem? target;
for (final item in state.items) {
if (item is ToolCallItem && item.callId == toolCallId) {
target = item;
break;
}
}
if (target == null) {
void _upsertById(List<ChatListItem> items, ChatListItem nextItem) {
final index = items.indexWhere((item) => item.id == nextItem.id);
if (index >= 0) {
items[index] = nextItem;
return;
}
final updatedItems = state.items.map((item) {
if (item is ToolCallItem && item.callId == toolCallId) {
return item.copyWith(
status: ToolCallStatus.executing,
errorMessage: null,
);
}
return item;
}).toList();
emit(
state.copyWith(
items: updatedItems,
isSending: false,
isWaitingFirstToken: true,
isStreaming: false,
isCancelling: false,
error: null,
),
);
try {
await _service.approveToolCall(
toolCallId: target.callId,
toolName: target.toolName,
args: target.args,
);
} catch (error) {
final failedItems = state.items.map((item) {
if (item is ToolCallItem && item.callId == toolCallId) {
return item.copyWith(
status: ToolCallStatus.error,
errorMessage: error.toString(),
);
}
return item;
}).toList();
emit(
state.copyWith(
items: failedItems,
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
error: error.toString(),
),
);
}
items.add(nextItem);
}
Future<String> transcribeAudioFile(String filePath) {
@@ -548,16 +481,17 @@ class ChatBloc extends Cubit<ChatState> {
if (pending != null) {
return pending;
}
final future = _service
.fetchAttachmentPreview(previewPath)
.then((bytes) {
_attachmentPreviewCache[previewPath] = bytes;
return bytes;
})
.catchError((_) => null)
.whenComplete(() {
_attachmentPreviewInflight.remove(previewPath);
});
final future = (() async {
try {
final bytes = await _service.fetchAttachmentPreview(previewPath);
_attachmentPreviewCache[previewPath] = bytes;
return bytes;
} catch (_) {
return null;
} finally {
_attachmentPreviewInflight.remove(previewPath);
}
})();
_attachmentPreviewInflight[previewPath] = future;
return future;
}
@@ -1,339 +1,398 @@
import 'package:flutter/material.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import '../../data/models/tool_result.dart';
/// 卡片类型常量
const _calendarCardType = 'calendar_card.v1';
const _calendarListType = 'calendar_event_list.v1';
const _calendarOperationType = 'calendar_operation.v1';
const _errorCardType = 'error_card.v1';
const _aiGeneratedSource = 'ai_generated';
const _agentGeneratedSource = 'agent_generated';
const _primaryActionType = 'primary';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/shared/widgets/toast/toast.dart';
import 'package:social_app/shared/widgets/toast/toast_type.dart';
class UiSchemaRenderer {
static Widget render(UiCard card) {
return switch (card.cardType) {
_calendarCardType => _renderCalendarCard(card),
_calendarListType => _renderCalendarList(card),
_calendarOperationType => _renderCalendarOperation(card),
_errorCardType => _renderErrorCard(card),
_ => _renderUnknownCard(card),
static Widget renderSchema(Map<String, dynamic>? schema) {
if (schema == null || schema.isEmpty) {
return const SizedBox.shrink();
}
final root = _asMap(schema['root']);
if (root == null) {
return _fallback('无效 UI Schema');
}
return _renderLayoutNode(root);
}
static Widget _renderLayoutNode(Map<String, dynamic> node) {
final type = _asString(node['type']);
return switch (type) {
'stack' => _renderStack(node),
'grid' => _renderGrid(node),
_ => _fallback('不支持的布局节点: $type'),
};
}
static Widget _renderCalendarCard(UiCard card) {
final data = CalendarCardData.fromJson(card.data);
final color = data.color != null
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
: AppColors.blue500;
final isAiGenerated =
data.sourceType == _aiGeneratedSource ||
data.sourceType == _agentGeneratedSource;
static Widget _renderNode(Map<String, dynamic> node) {
final type = _asString(node['type']);
if (node['visible'] == false) {
return const SizedBox.shrink();
}
return switch (type) {
'text' => _renderText(node),
'icon' => _renderIcon(node),
'badge' => _renderBadge(node),
'button' => _renderButton(node),
'kv' => _renderKv(node),
'divider' => _renderDivider(node),
'stack' => _renderStack(node),
'grid' => _renderGrid(node),
_ => _fallback('未知节点: $type'),
};
}
return Container(
decoration: BoxDecoration(
color: AppColors.messageCardBg,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.messageCardBorder),
),
child: Column(
static Widget _renderStack(Map<String, dynamic> node) {
final children = _asList(
node['children'],
).whereType<Map<String, dynamic>>().map(_renderNode).toList();
final gap = _asDouble(node['gap'], fallback: AppSpacing.sm);
final direction = _asString(node['direction'], fallback: 'vertical');
Widget content;
if (direction == 'horizontal') {
content = Wrap(
direction: Axis.horizontal,
spacing: gap,
runSpacing: gap,
crossAxisAlignment: WrapCrossAlignment.center,
children: children,
);
} else {
content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: AppSpacing.sm,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(AppRadius.lg),
topRight: Radius.circular(AppRadius.lg),
),
),
),
Padding(
padding: EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isAiGenerated) ...[
_buildAiTag(),
SizedBox(height: AppSpacing.sm),
],
Text(
_formatTime(data.startAt, data.endAt),
style: TextStyle(fontSize: 12, color: AppColors.slate500),
),
SizedBox(height: AppSpacing.sm),
Text(
data.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
if (data.description != null) ...[
SizedBox(height: AppSpacing.xs),
Text(
data.description!,
style: TextStyle(fontSize: 14, color: AppColors.slate600),
),
],
if (data.location != null) ...[
SizedBox(height: AppSpacing.sm),
_buildLocation(data.location!),
],
if (card.actions != null && card.actions!.isNotEmpty) ...[
SizedBox(height: AppSpacing.md),
_buildActions(card.actions!),
],
],
),
),
],
children: _withGap(children, gap),
);
}
return _wrapSurface(node, content);
}
static Widget _renderGrid(Map<String, dynamic> node) {
final children = _asList(
node['children'],
).whereType<Map<String, dynamic>>().map(_renderNode).toList();
final columns = _asInt(node['columns'], fallback: 2).clamp(1, 3);
final gap = _asDouble(node['gap'], fallback: AppSpacing.sm);
final tiles = List.generate(children.length, (index) => children[index]);
return _wrapSurface(
node,
GridView.count(
crossAxisCount: columns,
crossAxisSpacing: gap,
mainAxisSpacing: gap,
childAspectRatio: 1.6,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
children: tiles,
),
);
}
static Widget _buildAiTag() {
static Widget _renderText(Map<String, dynamic> node) {
final role = _asString(node['role'], fallback: 'body');
final status = _asString(node['status']);
final style = switch (role) {
'title' => const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
height: 1.25,
),
'subtitle' => const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
'caption' => const TextStyle(fontSize: 12, color: AppColors.slate500),
'code' => const TextStyle(
fontSize: 12,
color: AppColors.slate700,
fontFamily: 'monospace',
),
_ => const TextStyle(
fontSize: 14,
color: AppColors.slate700,
height: 1.45,
),
};
return Text(
_asString(node['content']),
maxLines: _asIntOrNull(node['maxLines']),
overflow: TextOverflow.ellipsis,
style: style.copyWith(color: _statusTextColor(status, style.color)),
);
}
static Widget _renderIcon(Map<String, dynamic> node) {
final value = _asString(node['value']);
if (_asString(node['source']) == 'emoji' && value.isNotEmpty) {
return Text(value, style: const TextStyle(fontSize: 20));
}
return Icon(Icons.bubble_chart_rounded, color: _statusTextColor('', null));
}
static Widget _renderBadge(Map<String, dynamic> node) {
final status = _asString(node['status']);
final fg =
_statusTextColor(status, AppColors.slate700) ?? AppColors.slate700;
final bg = _statusBackground(status);
return Container(
padding: EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.messageTagBg,
borderRadius: BorderRadius.circular(AppRadius.sm),
color: bg,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
'AI生成',
style: TextStyle(fontSize: 10, color: AppColors.blue600),
_asString(node['label']),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg),
),
);
}
static Widget _buildLocation(String location) {
return Row(
children: [
Icon(Icons.location_on_outlined, size: 16, color: AppColors.slate500),
SizedBox(width: AppSpacing.xs),
Text(
location,
style: TextStyle(fontSize: 12, color: AppColors.slate500),
),
],
static Widget _renderButton(Map<String, dynamic> node) {
final style = _asString(node['style'], fallback: 'secondary');
final action = _asMap(node['action']);
final disabled = node['disabled'] == true;
return Builder(
builder: (context) {
return ElevatedButton(
onPressed: disabled
? null
: () {
final actionType = _asString(action?['type']);
if (actionType == 'copy') {
Toast.show(context, '已复制', type: ToastType.success);
} else {
Toast.show(context, '该操作暂未接入', type: ToastType.info);
}
},
style: ElevatedButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
backgroundColor: style == 'primary'
? AppColors.blue600
: AppColors.homeComposerAccent,
foregroundColor: style == 'primary'
? AppColors.white
: AppColors.slate700,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
),
child: Text(
_asString(node['label'], fallback: '操作'),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
);
},
);
}
static Widget _buildActions(List<CardAction> actions) {
return Wrap(
spacing: AppSpacing.sm,
children: actions.map((action) => _buildActionButton(action)).toList(),
);
}
static Widget _buildActionButton(CardAction action) {
final isPrimary = action.type == _primaryActionType;
return GestureDetector(
onTap: () => _handleAction(action),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: isPrimary ? AppColors.blue500 : AppColors.messageBtnWrap,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(
color: isPrimary ? AppColors.blue500 : AppColors.messageBtnBorder,
),
),
child: Text(
action.label,
style: TextStyle(
fontSize: 14,
color: isPrimary ? AppColors.white : AppColors.slate600,
),
),
),
);
}
static Widget _renderCalendarList(UiCard card) {
final rawItems = card.data['items'];
final items = rawItems is List ? rawItems : const [];
final paginationRaw = card.data['pagination'];
final pagination = paginationRaw is Map<String, dynamic>
? paginationRaw
: const <String, dynamic>{};
final page = pagination['page'];
final total = pagination['total'];
return Container(
padding: EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.messageCardBg,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.messageCardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'日程列表',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
if (page != null || total != null) ...[
SizedBox(height: AppSpacing.xs),
Text(
'${page ?? '-'}页 · 共${total ?? '-'}',
style: TextStyle(fontSize: 12, color: AppColors.slate500),
),
],
SizedBox(height: AppSpacing.sm),
if (items.isEmpty)
Text(
'暂无日程',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
for (final item in items)
if (item is Map<String, dynamic>)
Padding(
padding: EdgeInsets.only(bottom: AppSpacing.xs),
static Widget _renderKv(Map<String, dynamic> node) {
final items = _asList(
node['items'],
).whereType<Map<String, dynamic>>().toList();
if (items.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _withGap(
items.map((item) {
final label = _asString(
item['label'],
fallback: _asString(item['key']),
);
final value = item['value']?.toString() ?? '-';
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Text(
item['title']?.toString() ?? '未命名日程',
style: TextStyle(fontSize: 14, color: AppColors.slate700),
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
),
),
),
],
const SizedBox(width: AppSpacing.sm),
Expanded(
flex: 5,
child: Text(
value,
style: const TextStyle(
fontSize: 13,
color: AppColors.slate800,
fontWeight: FontWeight.w500,
),
),
),
],
);
}).toList(),
AppSpacing.xs,
),
);
}
static Widget _renderCalendarOperation(UiCard card) {
final ok = card.data['ok'] == true;
final operation = card.data['operation']?.toString() ?? 'operation';
final message = card.data['message']?.toString() ?? (ok ? '操作成功' : '操作失败');
static Widget _renderDivider(Map<String, dynamic> node) {
final inset = _asDouble(node['inset'], fallback: 0);
return Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: const Divider(height: 1, color: AppColors.slate200),
);
}
static Widget _wrapSurface(Map<String, dynamic> node, Widget child) {
final appearance = _asString(node['appearance'], fallback: 'plain');
final status = _asString(node['status']);
if (appearance == 'plain') {
return child;
}
final bg = switch (appearance) {
'section' => AppColors.homeComposerInner,
_ => _statusBackground(status),
};
final borderColor = switch (status) {
'success' => AppColors.feedbackSuccessBorder,
'warning' => AppColors.feedbackWarningBorder,
'error' => AppColors.feedbackErrorBorder,
_ => AppColors.homeConversationBorder,
};
return Container(
padding: EdgeInsets.all(AppSpacing.lg),
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: ok ? AppColors.messageCardBg : AppColors.warningBackground,
color: bg,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: ok ? AppColors.messageCardBorder : AppColors.red400,
border: Border.all(color: borderColor),
boxShadow: [
BoxShadow(
color: AppColors.blue100.withValues(alpha: 0.35),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: child,
);
}
static Widget _fallback(String text) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.feedbackWarningSurface,
border: Border.all(color: AppColors.feedbackWarningBorder),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Text(
text,
style: const TextStyle(
fontSize: 12,
color: AppColors.feedbackWarningText,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'日程$operation结果',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: ok ? AppColors.slate900 : AppColors.red600,
),
),
SizedBox(height: AppSpacing.xs),
Text(
message,
style: TextStyle(
fontSize: 13,
color: ok ? AppColors.slate600 : AppColors.red600,
),
),
if (card.actions != null && card.actions!.isNotEmpty) ...[
SizedBox(height: AppSpacing.md),
_buildActions(card.actions!),
],
],
),
);
}
static Widget _renderErrorCard(UiCard card) {
final message = card.data['message'] as String? ?? '发生错误';
return Container(
padding: EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.warningBackground,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.red400),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 20, color: AppColors.red600),
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
message,
style: TextStyle(fontSize: 14, color: AppColors.red600),
),
),
],
),
);
}
static Widget _renderUnknownCard(UiCard card) {
return Container(
padding: EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.messageCardBg,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'未知卡片类型: ${card.cardType}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
SizedBox(height: AppSpacing.sm),
Text(
card.data.toString(),
style: TextStyle(fontSize: 12, color: AppColors.slate500),
),
],
),
);
}
static String _formatTime(String startAt, String? endAt) {
try {
final start = DateTime.parse(startAt);
final buffer = StringBuffer();
buffer.write('${start.month}${start.day}');
buffer.write(
'${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}',
);
if (endAt != null) {
final end = DateTime.parse(endAt);
buffer.write(
' - ${end.hour.toString().padLeft(2, '0')}:${end.minute.toString().padLeft(2, '0')}',
);
}
return buffer.toString();
} catch (e) {
return startAt;
static List<Widget> _withGap(List<Widget> widgets, double gap) {
if (widgets.isEmpty) {
return const [];
}
final result = <Widget>[];
for (var i = 0; i < widgets.length; i++) {
if (i > 0) {
result.add(SizedBox(height: gap));
}
result.add(widgets[i]);
}
return result;
}
static void _handleAction(CardAction action) {
// TODO: 实现 action 处理
static Color _statusBackground(String status) {
return switch (status) {
'success' => AppColors.feedbackSuccessSurface,
'warning' => AppColors.feedbackWarningSurface,
'error' => AppColors.feedbackErrorSurface,
'pending' => AppColors.feedbackInfoSurface,
_ => AppColors.homeConversationSurface,
};
}
static Color? _statusTextColor(String status, Color? fallback) {
return switch (status) {
'success' => AppColors.feedbackSuccessText,
'warning' => AppColors.feedbackWarningText,
'error' => AppColors.feedbackErrorText,
'pending' => AppColors.feedbackInfoText,
_ => fallback,
};
}
static Map<String, dynamic>? _asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
final result = <String, dynamic>{};
for (final entry in value.entries) {
if (entry.key is String) {
result[entry.key as String] = entry.value;
}
}
return result;
}
return null;
}
static List<dynamic> _asList(Object? value) {
return value is List ? value : const [];
}
static String _asString(Object? value, {String fallback = ''}) {
return value is String ? value : fallback;
}
static int _asInt(Object? value, {int fallback = 0}) {
if (value is int) {
return value;
}
if (value is double) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value) ?? fallback;
}
return fallback;
}
static int? _asIntOrNull(Object? value) {
if (value == null) {
return null;
}
return _asInt(value);
}
static double _asDouble(Object? value, {double fallback = 0}) {
if (value is double) {
return value;
}
if (value is int) {
return value.toDouble();
}
if (value is String) {
return double.tryParse(value) ?? fallback;
}
return fallback;
}
}