AI助手优化-头像和对话功能
This commit is contained in:
+41
-1
@@ -227,15 +227,17 @@ class AiApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 新增一套人设。
|
/// 新增一套人设。[avatar] 传 null 表示不设头像。
|
||||||
Future<AiPersonaItem> createPersona({
|
Future<AiPersonaItem> createPersona({
|
||||||
required String name,
|
required String name,
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
|
String? avatar,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final res = await _dio.post('/ai/personas', data: {
|
final res = await _dio.post('/ai/personas', data: {
|
||||||
'name': name,
|
'name': name,
|
||||||
'systemPrompt': systemPrompt,
|
'systemPrompt': systemPrompt,
|
||||||
|
'avatar': ?avatar,
|
||||||
});
|
});
|
||||||
return AiPersonaItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
return AiPersonaItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -246,15 +248,22 @@ class AiApi {
|
|||||||
|
|
||||||
/// 修改人设。提示词是**整体替换**的 —— 每次都把完整文本传上来,
|
/// 修改人设。提示词是**整体替换**的 —— 每次都把完整文本传上来,
|
||||||
/// 没有模型配置那种「key 留空 = 不改动」的语义。
|
/// 没有模型配置那种「key 留空 = 不改动」的语义。
|
||||||
|
///
|
||||||
|
/// 头像不一样:[avatar] 传 null = **不改动**(用户只改提示词时不该被迫重选头像),
|
||||||
|
/// [clearAvatar] 传 true = 清空回到默认头像。
|
||||||
Future<AiPersonaItem> updatePersona({
|
Future<AiPersonaItem> updatePersona({
|
||||||
required int id,
|
required int id,
|
||||||
required String name,
|
required String name,
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
|
String? avatar,
|
||||||
|
bool clearAvatar = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final res = await _dio.put('/ai/personas/$id', data: {
|
final res = await _dio.put('/ai/personas/$id', data: {
|
||||||
'name': name,
|
'name': name,
|
||||||
'systemPrompt': systemPrompt,
|
'systemPrompt': systemPrompt,
|
||||||
|
'avatar': ?avatar,
|
||||||
|
if (clearAvatar) 'clearAvatar': true,
|
||||||
});
|
});
|
||||||
return AiPersonaItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
return AiPersonaItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -263,6 +272,30 @@ class AiApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 上传人设头像,返回**相对路径**(再由 create/update 提交)。
|
||||||
|
///
|
||||||
|
/// 只落盘不落库 —— 用户在编辑弹层里可能传了又取消,不该留下记录。
|
||||||
|
Future<String> uploadPersonaAvatar(String filePath) async {
|
||||||
|
try {
|
||||||
|
final form = FormData.fromMap({
|
||||||
|
'file': await MultipartFile.fromFile(filePath, filename: _fileName(filePath)),
|
||||||
|
});
|
||||||
|
final res = await _dio.post<dynamic>(
|
||||||
|
'/ai/personas/avatar',
|
||||||
|
data: form,
|
||||||
|
options: Options(receiveTimeout: const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
final data = res.data;
|
||||||
|
if (data is Map && data['path'] != null) {
|
||||||
|
return data['path'].toString();
|
||||||
|
}
|
||||||
|
throw ApiException(-1, '上传失败:服务端没返回路径');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final err = e.error;
|
||||||
|
throw err is ApiException ? err : ApiException(-1, '头像上传失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deletePersona(int id) async {
|
Future<void> deletePersona(int id) async {
|
||||||
try {
|
try {
|
||||||
await _dio.delete('/ai/personas/$id');
|
await _dio.delete('/ai/personas/$id');
|
||||||
@@ -272,6 +305,13 @@ class AiApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从路径里取个带扩展名的文件名,后端靠它判断文件类型
|
||||||
|
static String _fileName(String path) {
|
||||||
|
final normalized = path.replaceAll('\\', '/');
|
||||||
|
final name = normalized.substring(normalized.lastIndexOf('/') + 1);
|
||||||
|
return name.isEmpty ? 'avatar.jpg' : name;
|
||||||
|
}
|
||||||
|
|
||||||
AiEvent? _parseEvent(String event, String payload) {
|
AiEvent? _parseEvent(String event, String payload) {
|
||||||
final decoded = jsonDecode(payload);
|
final decoded = jsonDecode(payload);
|
||||||
if (decoded is! Map<String, dynamic>) return null;
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
|||||||
@@ -39,6 +39,61 @@ class AuthApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉当前用户资料。冷启动时本地缓存的 userInfo 可能过期(在别处换过头像),
|
||||||
|
/// 用它刷一次。
|
||||||
|
Future<AuthResponse> profile() async {
|
||||||
|
try {
|
||||||
|
final res = await _dio.get('/auth/profile');
|
||||||
|
return AuthResponse.fromJson(_map(res.data));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _unwrap(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新资料。目前只有头像。
|
||||||
|
///
|
||||||
|
/// [avatar] 传 null = 不改动;[clearAvatar] 传 true = 清空。
|
||||||
|
/// 这两种语义要分清 —— 混淆会让「只改昵称」意外把头像清掉。
|
||||||
|
Future<AuthResponse> updateProfile({String? avatar, bool clearAvatar = false}) async {
|
||||||
|
try {
|
||||||
|
final res = await _dio.put('/auth/profile', data: {
|
||||||
|
'avatar': ?avatar,
|
||||||
|
if (clearAvatar) 'clearAvatar': true,
|
||||||
|
});
|
||||||
|
return AuthResponse.fromJson(_map(res.data));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _unwrap(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传头像,返回**相对路径**(再由 updateProfile 提交)。
|
||||||
|
Future<String> uploadAvatar(String filePath) async {
|
||||||
|
try {
|
||||||
|
final form = FormData.fromMap({
|
||||||
|
'file': await MultipartFile.fromFile(filePath, filename: _fileName(filePath)),
|
||||||
|
});
|
||||||
|
final res = await _dio.post<dynamic>(
|
||||||
|
'/auth/avatar',
|
||||||
|
data: form,
|
||||||
|
options: Options(receiveTimeout: const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
final data = res.data;
|
||||||
|
if (data is Map && data['path'] != null) {
|
||||||
|
return data['path'].toString();
|
||||||
|
}
|
||||||
|
throw ApiException(-1, '上传失败:服务端没返回路径');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _unwrap(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从路径取文件名,后端靠扩展名判断类型
|
||||||
|
static String _fileName(String path) {
|
||||||
|
final normalized = path.replaceAll('\\', '/');
|
||||||
|
final name = normalized.substring(normalized.lastIndexOf('/') + 1);
|
||||||
|
return name.isEmpty ? 'avatar.jpg' : name;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _map(Object? data) {
|
Map<String, dynamic> _map(Object? data) {
|
||||||
if (data is Map<String, dynamic>) return data;
|
if (data is Map<String, dynamic>) return data;
|
||||||
throw ApiException(-1, '响应格式异常');
|
throw ApiException(-1, '响应格式异常');
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ class LocalStorage {
|
|||||||
await prefs.remove(_keyUserInfo);
|
await prefs.remove(_keyUserInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 只更新用户信息,不动 token。
|
||||||
|
///
|
||||||
|
/// 改头像/昵称后用它 —— 那些操作不发新 token,不该走 [setAuth]
|
||||||
|
/// (那会要求重传 token,容易传错把登录态弄坏)。
|
||||||
|
Future<void> setUserInfo(Map<String, dynamic> userInfo) async {
|
||||||
|
final prefs = await _getPrefs();
|
||||||
|
await prefs.setString(_keyUserInfo, jsonEncode(userInfo));
|
||||||
|
}
|
||||||
|
|
||||||
String? getToken() => _prefs?.getString(_keyToken);
|
String? getToken() => _prefs?.getString(_keyToken);
|
||||||
|
|
||||||
Map<String, dynamic>? getUserInfo() {
|
Map<String, dynamic>? getUserInfo() {
|
||||||
|
|||||||
@@ -6,10 +6,24 @@ class ChatSessionItem {
|
|||||||
final String title;
|
final String title;
|
||||||
final String updateTime;
|
final String updateTime;
|
||||||
|
|
||||||
|
/// 本会话绑定的人设 id。null = 内置默认人设。
|
||||||
|
///
|
||||||
|
/// 会话的人设在**新建时确定、之后不可改** —— 一次对话从头到尾是同一个人。
|
||||||
|
final int? personaId;
|
||||||
|
|
||||||
|
/// 人设显示名。人设被删了会回落成「默认人设」,历史会话不会因此打不开
|
||||||
|
final String personaName;
|
||||||
|
|
||||||
|
/// 人设头像相对路径。null = 用默认头像
|
||||||
|
final String? personaAvatar;
|
||||||
|
|
||||||
const ChatSessionItem({
|
const ChatSessionItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.updateTime,
|
required this.updateTime,
|
||||||
|
this.personaId,
|
||||||
|
this.personaName = '默认人设',
|
||||||
|
this.personaAvatar,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ChatSessionItem.fromJson(Map<String, dynamic> json) {
|
factory ChatSessionItem.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -17,6 +31,9 @@ class ChatSessionItem {
|
|||||||
id: asInt(json['id']),
|
id: asInt(json['id']),
|
||||||
title: asNonEmpty(json['title'], '新对话'),
|
title: asNonEmpty(json['title'], '新对话'),
|
||||||
updateTime: formatDateTime(json['updateTime']),
|
updateTime: formatDateTime(json['updateTime']),
|
||||||
|
personaId: asNullableInt(json['personaId']),
|
||||||
|
personaName: asNonEmpty(json['personaName'], '默认人设'),
|
||||||
|
personaAvatar: asNonEmpty(json['personaAvatar'], ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AiPersonaItem {
|
|||||||
required this.isDefault,
|
required this.isDefault,
|
||||||
required this.editable,
|
required this.editable,
|
||||||
required this.defaultPrompt,
|
required this.defaultPrompt,
|
||||||
|
this.avatar,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// null = 虚拟的「默认」项(内容是服务端代码内置的)
|
/// null = 虚拟的「默认」项(内容是服务端代码内置的)
|
||||||
@@ -42,6 +43,10 @@ class AiPersonaItem {
|
|||||||
/// 以后改了默认人设,App 不用跟着改版本。
|
/// 以后改了默认人设,App 不用跟着改版本。
|
||||||
final String? defaultPrompt;
|
final String? defaultPrompt;
|
||||||
|
|
||||||
|
/// 头像**相对路径**(如 `persona/2026/09/29/xxx.jpg`)。
|
||||||
|
/// 用 [Avatar] 组件显示,它会自己拼 host。null = 用默认头像。
|
||||||
|
final String? avatar;
|
||||||
|
|
||||||
/// 用于「显示已选中的是哪一项」的稳定标识。
|
/// 用于「显示已选中的是哪一项」的稳定标识。
|
||||||
///
|
///
|
||||||
/// 默认项没有 id,用 -1 代替 —— 不能直接用 null 当 map key 去比较。
|
/// 默认项没有 id,用 -1 代替 —— 不能直接用 null 当 map key 去比较。
|
||||||
@@ -58,6 +63,7 @@ class AiPersonaItem {
|
|||||||
// 用户项上这个字段是 null —— 不能直接用 asNonEmpty(它要 fallback 参数,
|
// 用户项上这个字段是 null —— 不能直接用 asNonEmpty(它要 fallback 参数,
|
||||||
// 而我们恰恰需要保留「null = 没有」这个语义,供「恢复默认」按钮判断)
|
// 而我们恰恰需要保留「null = 没有」这个语义,供「恢复默认」按钮判断)
|
||||||
defaultPrompt: _nullableNonEmpty(json['defaultPrompt']),
|
defaultPrompt: _nullableNonEmpty(json['defaultPrompt']),
|
||||||
|
avatar: _nullableNonEmpty(json['avatar']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,21 @@ class AuthResponse {
|
|||||||
final String? username;
|
final String? username;
|
||||||
final String? nickname;
|
final String? nickname;
|
||||||
|
|
||||||
const AuthResponse({required this.token, this.username, this.nickname});
|
/// 头像相对路径。null = 用默认头像
|
||||||
|
final String? avatar;
|
||||||
|
|
||||||
|
const AuthResponse({
|
||||||
|
required this.token,
|
||||||
|
this.username,
|
||||||
|
this.nickname,
|
||||||
|
this.avatar,
|
||||||
|
});
|
||||||
|
|
||||||
factory AuthResponse.fromJson(Map<String, dynamic> json) => AuthResponse(
|
factory AuthResponse.fromJson(Map<String, dynamic> json) => AuthResponse(
|
||||||
token: json['token']?.toString() ?? '',
|
token: json['token']?.toString() ?? '',
|
||||||
username: json['username']?.toString(),
|
username: json['username']?.toString(),
|
||||||
nickname: json['nickname']?.toString(),
|
nickname: json['nickname']?.toString(),
|
||||||
|
avatar: json['avatar']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,13 +26,27 @@ class UserInfo {
|
|||||||
final String username;
|
final String username;
|
||||||
final String nickname;
|
final String nickname;
|
||||||
|
|
||||||
const UserInfo({this.id, required this.username, required this.nickname});
|
/// 头像相对路径。null = 用默认头像
|
||||||
|
final String? avatar;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {'id': id, 'username': username, 'nickname': nickname};
|
const UserInfo({
|
||||||
|
this.id,
|
||||||
|
required this.username,
|
||||||
|
required this.nickname,
|
||||||
|
this.avatar,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'username': username,
|
||||||
|
'nickname': nickname,
|
||||||
|
'avatar': avatar,
|
||||||
|
};
|
||||||
|
|
||||||
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
|
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
|
||||||
id: json['id']?.toString(),
|
id: json['id']?.toString(),
|
||||||
username: json['username']?.toString() ?? '',
|
username: json['username']?.toString() ?? '',
|
||||||
nickname: json['nickname']?.toString() ?? '',
|
nickname: json['nickname']?.toString() ?? '',
|
||||||
|
avatar: json['avatar']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
import '../../../api/ai_api.dart';
|
import '../../../api/ai_api.dart';
|
||||||
import '../../../core/network/api_exception.dart';
|
import '../../../core/network/api_exception.dart';
|
||||||
import '../../../models/ai_persona.dart';
|
import '../../../models/ai_persona.dart';
|
||||||
import '../../../providers/ai_persona_provider.dart';
|
import '../../../providers/ai_persona_provider.dart';
|
||||||
import '../../../utils/toast.dart';
|
import '../../../utils/toast.dart';
|
||||||
|
import '../../../widgets/avatar.dart';
|
||||||
import '../../../widgets/state_views.dart';
|
import '../../../widgets/state_views.dart';
|
||||||
|
|
||||||
/// 人设列表页(不含 Scaffold/AppBar —— 它作为「AI 设置」页的一个 Tab 嵌进去)
|
/// 人设列表页(不含 Scaffold/AppBar —— 它作为「AI 设置」页的一个 Tab 嵌进去)
|
||||||
@@ -154,6 +156,13 @@ class AiPersonaTab extends ConsumerWidget {
|
|||||||
active ? const Color(0xFFC8956E) : const Color(0xFFD7CCC8),
|
active ? const Color(0xFFC8956E) : const Color(0xFFD7CCC8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// 人设头像。没设过就是默认小人图标
|
||||||
|
Avatar(
|
||||||
|
assetPath: item.avatar,
|
||||||
|
size: 38,
|
||||||
|
fallbackIcon: 'assistant',
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -319,6 +328,20 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
|||||||
|
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
|
/// 当前头像相对路径。null = 没有头像。
|
||||||
|
///
|
||||||
|
/// 三种状态要分清(和后端语义对齐):
|
||||||
|
/// · 进来时的原值 → 不传,保持不动
|
||||||
|
/// · 用户选了新图 → 传新路径
|
||||||
|
/// · 用户点了「移除头像」→ 传 clearAvatar=true
|
||||||
|
String? _avatar;
|
||||||
|
|
||||||
|
/// 是否显式要求清空头像
|
||||||
|
bool _clearAvatar = false;
|
||||||
|
|
||||||
|
/// 上传中(转圈 + 禁止重复点)
|
||||||
|
bool _uploading = false;
|
||||||
|
|
||||||
bool get _isEdit => widget.existing != null;
|
bool get _isEdit => widget.existing != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -331,6 +354,7 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
|||||||
_prompt = TextEditingController(
|
_prompt = TextEditingController(
|
||||||
text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '',
|
text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '',
|
||||||
);
|
);
|
||||||
|
_avatar = e?.avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -340,6 +364,82 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从相册或相机选一张头像。
|
||||||
|
///
|
||||||
|
/// 这里**传了压缩参数**(和水印那个「要原图」的场景不同)——
|
||||||
|
/// 头像显示尺寸才 40px,传几 MB 的原图纯属浪费流量和服务器空间。
|
||||||
|
Future<void> _pickAvatar(ImageSource source) async {
|
||||||
|
if (_uploading) return;
|
||||||
|
try {
|
||||||
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: 80,
|
||||||
|
maxWidth: 800,
|
||||||
|
);
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _uploading = true);
|
||||||
|
final path = await ref.read(aiApiProvider).uploadPersonaAvatar(picked.path);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_avatar = path;
|
||||||
|
_clearAvatar = false;
|
||||||
|
});
|
||||||
|
showToast(context, '头像已上传,记得保存', icon: 'check');
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (mounted) showToast(context, e.message, error: true);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) showToast(context, '上传失败:$e', error: true);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _uploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底部选「拍照 / 相册」
|
||||||
|
Future<void> _showAvatarPicker() async {
|
||||||
|
final source = await showModalBottomSheet<ImageSource>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_camera_outlined, color: Color(0xFFC8956E)),
|
||||||
|
title: const Text('拍一张', style: TextStyle(fontSize: 14.5)),
|
||||||
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library_outlined, color: Color(0xFFC8956E)),
|
||||||
|
title: const Text('从相册选', style: TextStyle(fontSize: 14.5)),
|
||||||
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
|
||||||
|
),
|
||||||
|
if ((_avatar?.isNotEmpty ?? false))
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline, color: Color(0xFFE53935)),
|
||||||
|
title: const Text('移除头像',
|
||||||
|
style: TextStyle(fontSize: 14.5, color: Color(0xFFE53935))),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
setState(() {
|
||||||
|
_avatar = null;
|
||||||
|
_clearAvatar = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (source == null || !mounted) return;
|
||||||
|
await _pickAvatar(source);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final prompt = _prompt.text.trim();
|
final prompt = _prompt.text.trim();
|
||||||
if (prompt.isEmpty) {
|
if (prompt.isEmpty) {
|
||||||
@@ -350,16 +450,21 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
|||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(aiApiProvider);
|
final api = ref.read(aiApiProvider);
|
||||||
|
// 只有「选了新图」才传 avatar;没动就不传(后端保持原值)
|
||||||
|
final newAvatar = _clearAvatar ? null : _avatar;
|
||||||
if (_isEdit) {
|
if (_isEdit) {
|
||||||
await api.updatePersona(
|
await api.updatePersona(
|
||||||
id: widget.existing!.id!,
|
id: widget.existing!.id!,
|
||||||
name: _name.text.trim(),
|
name: _name.text.trim(),
|
||||||
systemPrompt: prompt,
|
systemPrompt: prompt,
|
||||||
|
avatar: newAvatar,
|
||||||
|
clearAvatar: _clearAvatar,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await api.createPersona(
|
await api.createPersona(
|
||||||
name: _name.text.trim(),
|
name: _name.text.trim(),
|
||||||
systemPrompt: prompt,
|
systemPrompt: prompt,
|
||||||
|
avatar: newAvatar,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -375,6 +480,69 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 头像选择区:一个圆形头像 + 下面一行小字。
|
||||||
|
///
|
||||||
|
/// 点它弹「拍照 / 相册 / 移除」的选择。已上传时头像立即预览
|
||||||
|
/// (用的是刚传回来的相对路径,本地也能看到)。
|
||||||
|
Widget _avatarPicker() {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _uploading ? null : _showAvatarPicker,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Avatar(
|
||||||
|
assetPath: _avatar,
|
||||||
|
size: 76,
|
||||||
|
fallbackIcon: 'assistant',
|
||||||
|
),
|
||||||
|
// 悬一个小相机角标,暗示「可以点」
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: const Color(0xFFC8956E),
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.camera_alt,
|
||||||
|
size: 12, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_uploading)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.black26,
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_uploading ? '上传中…' : '点头像换个形象',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _restoreDefault() {
|
void _restoreDefault() {
|
||||||
final def = ref.read(defaultPersonaPromptProvider);
|
final def = ref.read(defaultPersonaPromptProvider);
|
||||||
if (def == null || def.isEmpty) {
|
if (def == null || def.isEmpty) {
|
||||||
@@ -412,6 +580,10 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
|
// 头像放最上面 —— 它是「这个人长什么样」的第一印象
|
||||||
|
Center(child: _avatarPicker()),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
_field(
|
_field(
|
||||||
label: '名称',
|
label: '名称',
|
||||||
controller: _name,
|
controller: _name,
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import '../../core/network/api_env.dart';
|
|||||||
import '../../models/ai.dart';
|
import '../../models/ai.dart';
|
||||||
import '../../providers/ai_provider.dart';
|
import '../../providers/ai_provider.dart';
|
||||||
import '../../providers/ai_config_provider.dart';
|
import '../../providers/ai_config_provider.dart';
|
||||||
|
import '../../providers/ai_persona_provider.dart';
|
||||||
|
import '../../providers/auth_provider.dart';
|
||||||
import 'ai_config_page.dart';
|
import 'ai_config_page.dart';
|
||||||
import '../../utils/toast.dart';
|
import '../../utils/toast.dart';
|
||||||
|
import '../../widgets/avatar.dart';
|
||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
import '../../widgets/cute_icon.dart';
|
import '../../widgets/cute_icon.dart';
|
||||||
|
|
||||||
@@ -121,7 +124,8 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
_iconButton(
|
_iconButton(
|
||||||
icon: Icons.add_comment_outlined,
|
icon: Icons.add_comment_outlined,
|
||||||
tooltip: '新对话',
|
tooltip: '新对话',
|
||||||
onTap: () => ref.read(assistantProvider.notifier).newChat(),
|
// 新对话要先选人设 —— 选了之后这一轮就锁定它,中途不会换
|
||||||
|
onTap: () => _startNewChat(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -261,17 +265,59 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 新对话
|
||||||
|
|
||||||
|
/// 「新对话」:先让用户挑个人设,再开始。
|
||||||
|
///
|
||||||
|
/// 挑完就锁定在这一轮对话上(存进 AssistantState.personaId),
|
||||||
|
/// 之后不管用户在设置页怎么换人设,这轮都不会变 —— 这是刻意的,
|
||||||
|
/// 见 `AssistantState.personaId` 的注释。
|
||||||
|
///
|
||||||
|
/// 列表里第一项是「默认人设」,也就是什么都不选的状态。用户直接关掉弹层
|
||||||
|
/// (返回 null)时也按默认处理,不强迫他做选择。
|
||||||
|
Future<void> _startNewChat(BuildContext context) async {
|
||||||
|
final picked = await showModalBottomSheet<_PersonaChoice>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (_) => const _PersonaPickerSheet(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// 关掉弹层 = 用默认人设。不返回任何东西也照样开新对话,
|
||||||
|
// 免得用户以为「没点就是取消了」
|
||||||
|
ref.read(assistantProvider.notifier).newChat(personaId: picked?.personaId);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 气泡
|
// ---------------------------------------------------------------- 气泡
|
||||||
|
//
|
||||||
|
// 微信风格:左右两侧都带头像,气泡紧贴头像那一侧。
|
||||||
|
// · 对方(AI)头像在左,用**当前会话绑的人设头像**;没设过就是默认小人
|
||||||
|
// · 自己(用户)头像在右,用账号头像;没设过是默认小人
|
||||||
|
//
|
||||||
|
// 头像只给 36px —— 太大反而抢戏,微信也就这个量级。
|
||||||
|
|
||||||
Widget _bubble(BuildContext context, ChatBubble bubble) {
|
Widget _bubble(BuildContext context, ChatBubble bubble) {
|
||||||
|
final persona = ref.watch(activeAiPersonaProvider);
|
||||||
|
final user = ref.watch(authProvider).user;
|
||||||
|
|
||||||
if (bubble.isUser) {
|
if (bubble.isUser) {
|
||||||
return Align(
|
return _row(
|
||||||
alignment: Alignment.centerRight,
|
avatar: Avatar(
|
||||||
child: Container(
|
assetPath: user?.avatar,
|
||||||
margin: const EdgeInsets.only(top: 10, left: 48),
|
size: 36,
|
||||||
|
fallbackIcon: 'user',
|
||||||
|
),
|
||||||
|
bubble: Container(
|
||||||
|
margin: const EdgeInsets.only(left: 40),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFD9A87C)]),
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFFC8956E), Color(0xFFD9A87C)],
|
||||||
|
),
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(16),
|
topLeft: Radius.circular(16),
|
||||||
topRight: Radius.circular(16),
|
topRight: Radius.circular(16),
|
||||||
@@ -281,16 +327,25 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
),
|
),
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
bubble.content,
|
bubble.content,
|
||||||
style: const TextStyle(fontSize: 14.5, height: 1.5, color: Colors.white),
|
style: const TextStyle(
|
||||||
|
fontSize: 14.5,
|
||||||
|
height: 1.5,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
isUser: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Align(
|
return _row(
|
||||||
alignment: Alignment.centerLeft,
|
avatar: Avatar(
|
||||||
child: Container(
|
assetPath: persona?.avatar,
|
||||||
margin: const EdgeInsets.only(top: 10, right: 24),
|
size: 36,
|
||||||
|
fallbackIcon: 'assistant',
|
||||||
|
),
|
||||||
|
bubble: Container(
|
||||||
|
margin: const EdgeInsets.only(right: 40),
|
||||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -338,6 +393,35 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
isUser: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行 = 头像 + 气泡。用户侧左右镜像。
|
||||||
|
///
|
||||||
|
/// 头像用 [CrossAxisAlignment.start](贴顶)而不是居中 —— 回答很长时
|
||||||
|
/// 头像跟着垂直居中会飘到中间,微信也是贴顶的。
|
||||||
|
Widget _row({
|
||||||
|
required Widget avatar,
|
||||||
|
required Widget bubble,
|
||||||
|
required bool isUser,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: isUser
|
||||||
|
? [
|
||||||
|
Expanded(child: Align(alignment: Alignment.centerRight, child: bubble)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
avatar,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
avatar,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Align(alignment: Alignment.centerLeft, child: bubble)),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +582,7 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
// ---------------------------------------------------------------- 历史会话
|
// ---------------------------------------------------------------- 历史会话
|
||||||
|
|
||||||
Future<void> _showSessions(BuildContext context) async {
|
Future<void> _showSessions(BuildContext context) async {
|
||||||
final selected = await showModalBottomSheet<int>(
|
final selected = await showModalBottomSheet<ChatSessionItem>(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
@@ -508,7 +592,11 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
builder: (_) => const _SessionSheet(),
|
builder: (_) => const _SessionSheet(),
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
ref.read(assistantProvider.notifier).openSession(selected);
|
// 把人设一并带进去 —— 历史对话要用它当时绑的那个,
|
||||||
|
// 不能因为你现在换了默认人设就跟着变
|
||||||
|
ref
|
||||||
|
.read(assistantProvider.notifier)
|
||||||
|
.openSession(selected.id, personaId: selected.personaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 滚动
|
// ---------------------------------------------------------------- 滚动
|
||||||
@@ -537,6 +625,112 @@ class _TypingDots extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 新对话时选人设的结果
|
||||||
|
class _PersonaChoice {
|
||||||
|
const _PersonaChoice(this.personaId);
|
||||||
|
|
||||||
|
/// null = 内置默认人设
|
||||||
|
final int? personaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新对话的人设选择面板。
|
||||||
|
///
|
||||||
|
/// 为什么新对话要先选人设:人设一旦定了,这轮对话就不再变(换了会前后不一致)。
|
||||||
|
/// 所以在「开始一段对话」这个时刻选是最自然的 —— 而不是聊着聊着去设置页改。
|
||||||
|
class _PersonaPickerSheet extends ConsumerWidget {
|
||||||
|
const _PersonaPickerSheet();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final personasAsync = ref.watch(aiPersonasProvider);
|
||||||
|
// 默认选中「用户当前偏好的那套」,省得每次都点
|
||||||
|
final preferred = ref.watch(selectedAiPersonaProvider);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'这次想找谁聊?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'选定后整段对话都是这个人,中途不会换',
|
||||||
|
style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Flexible(
|
||||||
|
child: personasAsync.when(
|
||||||
|
loading: () => const LoadingView(tip: '加载中…'),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: '人设加载失败',
|
||||||
|
onRetry: () => ref.invalidate(aiPersonasProvider),
|
||||||
|
),
|
||||||
|
data: (list) {
|
||||||
|
if (list.isEmpty) {
|
||||||
|
// 极端情况:后端一个人设都没有。不该卡住用户,
|
||||||
|
// 直接给一个「用默认」的出口
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
leading: const Avatar(size: 38, fallbackIcon: 'assistant'),
|
||||||
|
title: const Text('默认人设', style: TextStyle(fontSize: 14.5)),
|
||||||
|
onTap: () => Navigator.pop(context, const _PersonaChoice(null)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: list.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final p = list[index];
|
||||||
|
final active = p.selectionKey == (preferred ?? -1);
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
leading: Avatar(
|
||||||
|
assetPath: p.avatar,
|
||||||
|
size: 42,
|
||||||
|
fallbackIcon: 'assistant',
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
p.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.5,
|
||||||
|
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: const Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
p.isDefault ? '代码内置的那套' : p.preview,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||||
|
),
|
||||||
|
trailing: active
|
||||||
|
? const Icon(Icons.check_circle,
|
||||||
|
size: 19, color: Color(0xFFC8956E))
|
||||||
|
: null,
|
||||||
|
onTap: () => Navigator.pop(context, _PersonaChoice(p.id)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 历史会话面板
|
/// 历史会话面板
|
||||||
class _SessionSheet extends ConsumerWidget {
|
class _SessionSheet extends ConsumerWidget {
|
||||||
const _SessionSheet();
|
const _SessionSheet();
|
||||||
@@ -578,6 +772,13 @@ class _SessionSheet extends ConsumerWidget {
|
|||||||
final s = sessions[index];
|
final s = sessions[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
// 会话头像 = 那条会话绑定的人设头像。
|
||||||
|
// 一眼看出「这段对话是谁在陪你」,不用点进去
|
||||||
|
leading: Avatar(
|
||||||
|
assetPath: s.personaAvatar,
|
||||||
|
size: 38,
|
||||||
|
fallbackIcon: 'assistant',
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
s.title,
|
s.title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -585,7 +786,9 @@ class _SessionSheet extends ConsumerWidget {
|
|||||||
style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)),
|
style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
s.updateTime,
|
'${s.personaName} · ${s.updateTime}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
@@ -594,7 +797,7 @@ class _SessionSheet extends ConsumerWidget {
|
|||||||
color: const Color(0xFFBCAAA4),
|
color: const Color(0xFFBCAAA4),
|
||||||
onPressed: () => _confirmDelete(context, ref, s),
|
onPressed: () => _confirmDelete(context, ref, s),
|
||||||
),
|
),
|
||||||
onTap: () => Navigator.pop(context, s.id),
|
onTap: () => Navigator.pop(context, s),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import '../../api/auth_api.dart';
|
||||||
import '../../core/module/app_module.dart';
|
import '../../core/module/app_module.dart';
|
||||||
import '../../core/network/api_exception.dart';
|
import '../../core/network/api_exception.dart';
|
||||||
import '../../core/network/auth_redirect.dart';
|
import '../../core/network/auth_redirect.dart';
|
||||||
@@ -12,6 +14,7 @@ import '../../providers/budget_provider.dart';
|
|||||||
import '../../utils/amount_input_formatter.dart';
|
import '../../utils/amount_input_formatter.dart';
|
||||||
import '../../utils/amount_util.dart';
|
import '../../utils/amount_util.dart';
|
||||||
import '../../utils/toast.dart';
|
import '../../utils/toast.dart';
|
||||||
|
import '../../widgets/avatar.dart';
|
||||||
import '../../widgets/cute_icon.dart';
|
import '../../widgets/cute_icon.dart';
|
||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
|
|
||||||
@@ -47,7 +50,7 @@ class MinePage extends ConsumerWidget {
|
|||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
_userCard(user?.nickname ?? '用户', user?.username ?? '-'),
|
_userCard(context, ref, user?.nickname ?? '用户', user?.username ?? '-'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_accountCard(context, ref, accountAsync),
|
_accountCard(context, ref, accountAsync),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@@ -78,7 +81,9 @@ class MinePage extends ConsumerWidget {
|
|||||||
future.then<void>((_) {}, onError: (_, _) {});
|
future.then<void>((_) {}, onError: (_, _) {});
|
||||||
|
|
||||||
// ---------------- 用户信息 ----------------
|
// ---------------- 用户信息 ----------------
|
||||||
Widget _userCard(String nickname, String username) {
|
Widget _userCard(BuildContext context, WidgetRef ref, String nickname, String username) {
|
||||||
|
final avatar = ref.watch(authProvider).user?.avatar;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -97,12 +102,15 @@ class MinePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// 头像。点它换 —— 这是全 App 唯一的用户头像,聊天气泡用的也是它
|
||||||
width: 60,
|
GestureDetector(
|
||||||
height: 60,
|
onTap: () => _pickAvatar(context, ref),
|
||||||
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
|
child: Avatar(
|
||||||
alignment: Alignment.center,
|
assetPath: avatar,
|
||||||
child: const CuteIcon('user', size: 32),
|
size: 60,
|
||||||
|
fallbackIcon: 'user',
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -125,11 +133,65 @@ class MinePage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 提示这个区域可点,不然没人知道能换头像
|
||||||
|
const Icon(Icons.chevron_right, size: 20, color: Color(0xFF6D4C41)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 换头像:选图 → 上传 → 更新资料 → 刷新本地用户信息
|
||||||
|
Future<void> _pickAvatar(BuildContext context, WidgetRef ref) async {
|
||||||
|
final source = await showModalBottomSheet<ImageSource>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_camera_outlined, color: Color(0xFFC8956E)),
|
||||||
|
title: const Text('拍一张', style: TextStyle(fontSize: 14.5)),
|
||||||
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library_outlined, color: Color(0xFFC8956E)),
|
||||||
|
title: const Text('从相册选', style: TextStyle(fontSize: 14.5)),
|
||||||
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (source == null || !context.mounted) return;
|
||||||
|
|
||||||
|
final api = ref.read(authApiProvider);
|
||||||
|
try {
|
||||||
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: 80,
|
||||||
|
maxWidth: 800,
|
||||||
|
);
|
||||||
|
if (picked == null || !context.mounted) return;
|
||||||
|
|
||||||
|
// 上传拿路径,再提交资料 —— 两步是为了「传了但没保存」不留下记录
|
||||||
|
final path = await api.uploadAvatar(picked.path);
|
||||||
|
final res = await api.updateProfile(avatar: path);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
await ref.read(authProvider.notifier).updateUserFromResponse(res);
|
||||||
|
if (context.mounted) showToast(context, '头像已更新', icon: 'check');
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (context.mounted) showToast(context, e.message, error: true);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) showToast(context, '换头像失败:$e', error: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- 账户卡 ----------------
|
// ---------------- 账户卡 ----------------
|
||||||
Widget _accountCard(BuildContext context, WidgetRef ref, AsyncValue<AccountInfo> accountAsync) {
|
Widget _accountCard(BuildContext context, WidgetRef ref, AsyncValue<AccountInfo> accountAsync) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import '../api/ai_api.dart';
|
|||||||
import '../core/network/api_exception.dart';
|
import '../core/network/api_exception.dart';
|
||||||
import '../models/ai.dart';
|
import '../models/ai.dart';
|
||||||
import 'ai_config_provider.dart';
|
import 'ai_config_provider.dart';
|
||||||
import 'ai_persona_provider.dart';
|
|
||||||
import 'bill_refresh.dart';
|
import 'bill_refresh.dart';
|
||||||
import 'note_provider.dart';
|
import 'note_provider.dart';
|
||||||
|
|
||||||
@@ -36,6 +35,15 @@ class AssistantState {
|
|||||||
/// 正在拉历史消息
|
/// 正在拉历史消息
|
||||||
final bool loadingHistory;
|
final bool loadingHistory;
|
||||||
|
|
||||||
|
/// 当前这次对话锁定的**人设 id**。null = 内置默认人设。
|
||||||
|
///
|
||||||
|
/// 这是「一次对话从头到尾是同一个人」的实现点:
|
||||||
|
/// · 新建会话时定下来(用户在选人设弹层里选的,或默认用全局那个)
|
||||||
|
/// · 打开历史会话时从会话记录里读出来
|
||||||
|
/// · 之后每次 send 都用它,**不再看全局的 selectedAiPersonaProvider** ——
|
||||||
|
/// 否则用户在设置页切一下人设,正在聊的这轮就换性格了
|
||||||
|
final int? personaId;
|
||||||
|
|
||||||
const AssistantState({
|
const AssistantState({
|
||||||
this.sessionId,
|
this.sessionId,
|
||||||
this.bubbles = const [],
|
this.bubbles = const [],
|
||||||
@@ -43,6 +51,7 @@ class AssistantState {
|
|||||||
this.activeTool,
|
this.activeTool,
|
||||||
this.error,
|
this.error,
|
||||||
this.loadingHistory = false,
|
this.loadingHistory = false,
|
||||||
|
this.personaId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 真的一句都没有(不是加载中、也不是出错)
|
/// 真的一句都没有(不是加载中、也不是出错)
|
||||||
@@ -55,6 +64,7 @@ class AssistantState {
|
|||||||
Object? activeTool = _sentinel,
|
Object? activeTool = _sentinel,
|
||||||
Object? error = _sentinel,
|
Object? error = _sentinel,
|
||||||
bool? loadingHistory,
|
bool? loadingHistory,
|
||||||
|
Object? personaId = _sentinel,
|
||||||
}) {
|
}) {
|
||||||
return AssistantState(
|
return AssistantState(
|
||||||
sessionId: sessionId == _sentinel ? this.sessionId : sessionId as int?,
|
sessionId: sessionId == _sentinel ? this.sessionId : sessionId as int?,
|
||||||
@@ -63,6 +73,7 @@ class AssistantState {
|
|||||||
activeTool: activeTool == _sentinel ? this.activeTool : activeTool as String?,
|
activeTool: activeTool == _sentinel ? this.activeTool : activeTool as String?,
|
||||||
error: error == _sentinel ? this.error : error as String?,
|
error: error == _sentinel ? this.error : error as String?,
|
||||||
loadingHistory: loadingHistory ?? this.loadingHistory,
|
loadingHistory: loadingHistory ?? this.loadingHistory,
|
||||||
|
personaId: personaId == _sentinel ? this.personaId : personaId as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,16 +110,23 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
// 会话列表本来就是按最近活跃倒序,第一条就是「上一次的对话」
|
// 会话列表本来就是按最近活跃倒序,第一条就是「上一次的对话」
|
||||||
final sessions = await ref.read(aiApiProvider).sessions();
|
final sessions = await ref.read(aiApiProvider).sessions();
|
||||||
if (!ref.mounted || sessions.isEmpty) return;
|
if (!ref.mounted || sessions.isEmpty) return;
|
||||||
await openSession(sessions.first.id);
|
// 带上这个人设 —— 恢复出来的对话得用它自己绑定的那个,不是全局偏好
|
||||||
|
await openSession(sessions.first.id, personaId: sessions.first.personaId);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 拉不到就算了,用户还能从「历史对话」里自己找
|
// 拉不到就算了,用户还能从「历史对话」里自己找
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 新对话:清空界面。后端那条旧会话还在,能从历史里翻出来。
|
/// 新对话。
|
||||||
void newChat() {
|
///
|
||||||
|
/// [personaId] 是这次对话要用的人设,**传了就锁定**,之后这轮不会再变。
|
||||||
|
/// 传 null 表示用内置默认人设。
|
||||||
|
///
|
||||||
|
/// 注意这里**不传全局的 selectedAiPersonaProvider** —— 那是「默认选哪套」的偏好,
|
||||||
|
/// 由调用方(页面上的选人设弹层)决定要不要用它兜底。
|
||||||
|
void newChat({int? personaId}) {
|
||||||
_autoRestored = true;
|
_autoRestored = true;
|
||||||
state = const AssistantState();
|
state = AssistantState(personaId: personaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearError() {
|
void clearError() {
|
||||||
@@ -116,19 +134,28 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 打开历史会话
|
/// 打开历史会话
|
||||||
Future<void> openSession(int sessionId) async {
|
///
|
||||||
state = AssistantState(sessionId: sessionId, loadingHistory: true);
|
/// 人设从**会话记录**里读,而不是用全局偏好 —— 三个月前那次对话是谁聊的,
|
||||||
|
/// 现在打开还得是谁,不能因为你现在改了默认人设就变。
|
||||||
|
Future<void> openSession(int sessionId, {int? personaId}) async {
|
||||||
|
state = AssistantState(
|
||||||
|
sessionId: sessionId,
|
||||||
|
personaId: personaId,
|
||||||
|
loadingHistory: true,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final items = await ref.read(aiApiProvider).messages(sessionId);
|
final items = await ref.read(aiApiProvider).messages(sessionId);
|
||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
state = AssistantState(
|
state = AssistantState(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
|
personaId: personaId,
|
||||||
bubbles: items.map(ChatBubble.fromItem).toList(),
|
bubbles: items.map(ChatBubble.fromItem).toList(),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
state = AssistantState(
|
state = AssistantState(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
|
personaId: personaId,
|
||||||
error: e is ApiException ? e.message : '消息加载失败',
|
error: e is ApiException ? e.message : '消息加载失败',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,10 +186,15 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
// 这次对话动过哪些数据
|
// 这次对话动过哪些数据
|
||||||
final mutated = <_Mutated>{};
|
final mutated = <_Mutated>{};
|
||||||
try {
|
try {
|
||||||
// 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项)
|
// 模型还是全局偏好(换模型是想马上生效的)
|
||||||
final configId = ref.read(selectedAiConfigProvider);
|
final configId = ref.read(selectedAiConfigProvider);
|
||||||
// 人设是独立的另一套。null = 用服务端内置的默认人设
|
|
||||||
final personaId = ref.read(selectedAiPersonaProvider);
|
// 人设**不用全局偏好,用这次对话锁定的那个**。
|
||||||
|
//
|
||||||
|
// 这是「一个会话里始终是同一个人设」的关键:state.personaId 在新建会话
|
||||||
|
// 或打开历史会话时就定了,中途用户在设置页怎么换都不影响正在聊的这轮。
|
||||||
|
// 想换人设得开新会话 —— 这正是需求要的语义。
|
||||||
|
final personaId = state.personaId;
|
||||||
await for (final event in ref.read(aiApiProvider).chat(
|
await for (final event in ref.read(aiApiProvider).chat(
|
||||||
sessionId: state.sessionId,
|
sessionId: state.sessionId,
|
||||||
configId: configId,
|
configId: configId,
|
||||||
|
|||||||
@@ -41,11 +41,26 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
final user = UserInfo(
|
final user = UserInfo(
|
||||||
username: response.username ?? '',
|
username: response.username ?? '',
|
||||||
nickname: response.nickname ?? response.username ?? '',
|
nickname: response.nickname ?? response.username ?? '',
|
||||||
|
avatar: response.avatar,
|
||||||
);
|
);
|
||||||
await LocalStorage.instance.setAuth(token: response.token, userInfo: user.toJson());
|
await LocalStorage.instance.setAuth(token: response.token, userInfo: user.toJson());
|
||||||
state = AuthState(token: response.token, user: user);
|
state = AuthState(token: response.token, user: user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 改完资料后刷新本地缓存的用户信息。
|
||||||
|
///
|
||||||
|
/// 不发新 token —— 后端返回的 [AuthResponse.token] 是 null,
|
||||||
|
/// 所以这里保留原来的 token,只换 user。
|
||||||
|
Future<void> updateUserFromResponse(AuthResponse response) async {
|
||||||
|
final user = UserInfo(
|
||||||
|
username: response.username ?? state.user?.username ?? '',
|
||||||
|
nickname: response.nickname ?? state.user?.nickname ?? '',
|
||||||
|
avatar: response.avatar,
|
||||||
|
);
|
||||||
|
await LocalStorage.instance.setUserInfo(user.toJson());
|
||||||
|
state = AuthState(token: state.token, user: user);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
await LocalStorage.instance.clearAuth();
|
await LocalStorage.instance.clearAuth();
|
||||||
state = const AuthState.initial();
|
state = const AuthState.initial();
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/network/api_env.dart';
|
||||||
|
import 'cute_icon.dart';
|
||||||
|
|
||||||
|
/// 圆形头像。全 App 只有这一个头像组件 —— 人设、用户、「我的」页面、聊天气泡都用它。
|
||||||
|
///
|
||||||
|
/// 三种输入都要兜住:
|
||||||
|
/// · [assetPath] 为空 → 画默认图标([fallbackIcon])
|
||||||
|
/// · 图片加载中 → 显示一个淡淡的占位圆
|
||||||
|
/// · 加载失败(后端文件被删了 / 网络断了)→ 退回默认图标,不显示裂图
|
||||||
|
///
|
||||||
|
/// 为什么不用 `CircleAvatar`:它的 backgroundImage 出错时不好接管,
|
||||||
|
/// 会有「裂图 + 背景色」的难看状态。自己用 [ClipOval] + [Image.network] 能完全控制。
|
||||||
|
class Avatar extends StatelessWidget {
|
||||||
|
const Avatar({
|
||||||
|
super.key,
|
||||||
|
this.assetPath,
|
||||||
|
this.size = 40,
|
||||||
|
this.fallbackIcon = 'user',
|
||||||
|
this.fallbackColor = const Color(0xFFC8956E),
|
||||||
|
this.backgroundColor = const Color(0xFFFFF3E6),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 后端返回的**相对路径**(如 `persona/2026/09/29/xxx.jpg`)。
|
||||||
|
/// 传空或 null → 用默认图标。
|
||||||
|
final String? assetPath;
|
||||||
|
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
/// 没有头像时显示哪个小图标(`CuteIcon` 的名字)
|
||||||
|
final String fallbackIcon;
|
||||||
|
|
||||||
|
/// 默认图标的颜色
|
||||||
|
final Color fallbackColor;
|
||||||
|
|
||||||
|
/// 头像底圈的颜色
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final path = assetPath?.trim() ?? '';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: backgroundColor,
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: path.isEmpty ? _fallback() : _image(path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fallback() {
|
||||||
|
return Center(
|
||||||
|
child: CuteIcon(fallbackIcon, size: size * 0.58),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _image(String path) {
|
||||||
|
return Image.network(
|
||||||
|
resolveAssetUrl('/uploads/$path'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
// 头像显示尺寸很小,限一下解码尺寸省内存 —— 原图可能是 400px
|
||||||
|
cacheWidth: (size * 3).round(),
|
||||||
|
loadingBuilder: (context, child, progress) {
|
||||||
|
// 还没加载出来时给个纯色圆,比空白或转圈干净
|
||||||
|
if (progress == null) return child;
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
errorBuilder: (context, _, _) {
|
||||||
|
// 后端文件被删了也会走到这 —— 退回默认图标,不留裂图
|
||||||
|
return _fallback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ class _FakeAiApi extends AiApi {
|
|||||||
Future<AiPersonaItem> createPersona({
|
Future<AiPersonaItem> createPersona({
|
||||||
required String name,
|
required String name,
|
||||||
required String systemPrompt,
|
required String systemPrompt,
|
||||||
|
String? avatar,
|
||||||
}) async =>
|
}) async =>
|
||||||
AiPersonaItem(
|
AiPersonaItem(
|
||||||
id: 99,
|
id: 99,
|
||||||
@@ -75,6 +76,7 @@ class _FakeAiApi extends AiApi {
|
|||||||
isDefault: false,
|
isDefault: false,
|
||||||
editable: true,
|
editable: true,
|
||||||
defaultPrompt: null,
|
defaultPrompt: null,
|
||||||
|
avatar: avatar,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/api/ai_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/ai.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/ai_persona.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/ai_provider.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/ai_persona_provider.dart';
|
||||||
|
|
||||||
|
/// 「会话内人设固定」的回归测试。
|
||||||
|
///
|
||||||
|
/// 这是这次需求里**最容易悄悄坏掉**的一条:人设现在是「每轮 send 时现读」还是
|
||||||
|
/// 「建会话时定下」,代码上只差一行 —— 但坏掉的表现是「聊到一半助手换了性格」,
|
||||||
|
/// 而且要用户真的去设置页切一次人设才能发现。
|
||||||
|
///
|
||||||
|
/// 所以这里直接断言「send 时带上的是 state.personaId,不是全局偏好」。
|
||||||
|
class _CapturingAiApi extends AiApi {
|
||||||
|
_CapturingAiApi() : super(Dio());
|
||||||
|
|
||||||
|
/// 每次 chat 调用时带上的 personaId,按调用顺序记下来
|
||||||
|
final List<int?> personaIdsSent = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<AiEvent> chat({
|
||||||
|
int? sessionId,
|
||||||
|
int? configId,
|
||||||
|
int? personaId,
|
||||||
|
required String content,
|
||||||
|
}) async* {
|
||||||
|
personaIdsSent.add(personaId);
|
||||||
|
// 模拟一个最小可用的回答,让 send() 能正常走完
|
||||||
|
yield const AiEvent.delta('好');
|
||||||
|
yield const AiEvent.done(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ChatSessionItem>> sessions() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AiPersonaItem>> personas() async => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// LocalStorage 是单例,select() 会写盘。测试里给它一个空实现,
|
||||||
|
// 否则会去真的要 SharedPreferences(在纯单测环境里拿不到)
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
ProviderContainer makeContainer(_CapturingAiApi api) {
|
||||||
|
final c = ProviderContainer(overrides: [
|
||||||
|
aiApiProvider.overrideWithValue(api),
|
||||||
|
]);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
group('★ 会话内人设固定', () {
|
||||||
|
test('新对话选的人设会一直用下去(中途全局偏好变了也不受影响)', () async {
|
||||||
|
final api = _CapturingAiApi();
|
||||||
|
final c = makeContainer(api);
|
||||||
|
final notifier = c.read(assistantProvider.notifier);
|
||||||
|
|
||||||
|
// 用户在「新对话」弹层里选了人设 7
|
||||||
|
notifier.newChat(personaId: 7);
|
||||||
|
expect(c.read(assistantProvider).personaId, 7);
|
||||||
|
|
||||||
|
// 中途用户在设置页把全局偏好换成了 9
|
||||||
|
c.read(selectedAiPersonaProvider.notifier).select(9);
|
||||||
|
|
||||||
|
// 继续在这个会话里说话
|
||||||
|
await notifier.send('第一句');
|
||||||
|
await notifier.send('第二句');
|
||||||
|
|
||||||
|
// 两次都该用 7,不该跟着全局偏好变成 9
|
||||||
|
expect(api.personaIdsSent, [7, 7],
|
||||||
|
reason: '会话锁定的人设不该被全局偏好覆盖');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新对话不选人设时用 null(内置默认)', () async {
|
||||||
|
final api = _CapturingAiApi();
|
||||||
|
final c = makeContainer(api);
|
||||||
|
final notifier = c.read(assistantProvider.notifier);
|
||||||
|
|
||||||
|
notifier.newChat();
|
||||||
|
expect(c.read(assistantProvider).personaId, isNull);
|
||||||
|
|
||||||
|
await notifier.send('随便说点');
|
||||||
|
|
||||||
|
expect(api.personaIdsSent, [null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('newChat 会清掉上一个人设(不残留)', () async {
|
||||||
|
final api = _CapturingAiApi();
|
||||||
|
final c = makeContainer(api);
|
||||||
|
final notifier = c.read(assistantProvider.notifier);
|
||||||
|
|
||||||
|
notifier.newChat(personaId: 3);
|
||||||
|
expect(c.read(assistantProvider).personaId, 3);
|
||||||
|
|
||||||
|
notifier.newChat();
|
||||||
|
expect(c.read(assistantProvider).personaId, isNull,
|
||||||
|
reason: '第二次新对话没选人设,不该残留上一次的');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('打开历史会话时用那条会话绑定的人设', () async {
|
||||||
|
final api = _CapturingAiApi();
|
||||||
|
final c = makeContainer(api);
|
||||||
|
final notifier = c.read(assistantProvider.notifier);
|
||||||
|
|
||||||
|
// 打开一个三个月前的会话,它绑的是人设 5
|
||||||
|
await notifier.openSession(42, personaId: 5);
|
||||||
|
expect(c.read(assistantProvider).personaId, 5);
|
||||||
|
expect(c.read(assistantProvider).sessionId, 42);
|
||||||
|
|
||||||
|
// 在这条历史会话里继续说话,仍然用 5
|
||||||
|
await notifier.send('接着聊');
|
||||||
|
|
||||||
|
expect(api.personaIdsSent, [5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ChatSessionItem 解析(会话列表带人设)', () {
|
||||||
|
test('完整字段', () {
|
||||||
|
final s = ChatSessionItem.fromJson({
|
||||||
|
'id': 1,
|
||||||
|
'title': '聊记账',
|
||||||
|
'updateTime': '2026-09-29 10:00:00',
|
||||||
|
'personaId': 7,
|
||||||
|
'personaName': '花花',
|
||||||
|
'personaAvatar': 'persona/a.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(s.id, 1);
|
||||||
|
expect(s.personaId, 7);
|
||||||
|
expect(s.personaName, '花花');
|
||||||
|
expect(s.personaAvatar, 'persona/a.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('★ 缺字段不崩(老后端 / 人设已删)', () {
|
||||||
|
final s = ChatSessionItem.fromJson({'id': 1, 'title': 'x'});
|
||||||
|
|
||||||
|
expect(s.personaId, isNull);
|
||||||
|
expect(s.personaName, '默认人设');
|
||||||
|
// 头像空串归一成 null,Avatar 组件据此显示默认图标
|
||||||
|
expect(s.personaAvatar, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('人设名缺失时回落成「默认人设」,不显示空白', () {
|
||||||
|
final s = ChatSessionItem.fromJson({'id': 1, 'personaName': ''});
|
||||||
|
expect(s.personaName, '默认人设');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/widgets/avatar.dart';
|
||||||
|
|
||||||
|
/// 头像组件的测试。
|
||||||
|
///
|
||||||
|
/// 重点是**不崩**和**不留裂图** —— 头像是从后端 /uploads 拉的,
|
||||||
|
/// 文件被删、地址写错、网络断了都会走到 errorBuilder,
|
||||||
|
/// 那时候必须退回默认图标,而不是显示一个裂开的图。
|
||||||
|
void main() {
|
||||||
|
Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
|
||||||
|
|
||||||
|
group('Avatar 兜底', () {
|
||||||
|
testWidgets('assetPath 为 null 时显示默认图标,不发起网络请求', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const Avatar(size: 40)));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// 没有 Image 组件 —— 说明压根没去加载图片
|
||||||
|
expect(find.byType(Image), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('assetPath 为空串时也走默认图标', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const Avatar(assetPath: '', size: 40)));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(Image), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('只有空白字符的路径也当没有(后端可能返回 " ")', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const Avatar(assetPath: ' ', size: 40)));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(Image), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('★ 给了路径就渲染图片,且加载失败时不崩(退回默认图标)', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
const Avatar(assetPath: 'persona/2026/09/29/x.jpg', size: 40),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(Image), findsOneWidget);
|
||||||
|
|
||||||
|
// 测试环境里网络请求必然失败,会走 errorBuilder。
|
||||||
|
// 这里断言的是「失败被接住了,没有把异常抛到 widget 树」
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(tester.takeException(), isNull,
|
||||||
|
reason: '图片加载失败必须被 errorBuilder 接住,不能抛异常');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Avatar 尺寸', () {
|
||||||
|
testWidgets('尺寸按传入值设置(聊天气泡和「我的」页面用不同大小)', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const Avatar(size: 36)));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final container = tester.widget<Container>(find.byType(Container).first);
|
||||||
|
final size = container.constraints;
|
||||||
|
expect(size?.maxWidth, 36);
|
||||||
|
expect(size?.maxHeight, 36);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('不同尺寸都不溢出', (tester) async {
|
||||||
|
for (final s in [24.0, 36.0, 40.0, 60.0, 76.0]) {
|
||||||
|
await tester.pumpWidget(wrap(Avatar(size: s)));
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.takeException(), isNull, reason: '尺寸 $s 出问题了');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('在很窄的容器里也不溢出', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
const SizedBox(width: 40, height: 40, child: Avatar(size: 36)),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user