AI助手优化-头像和对话功能

This commit is contained in:
2026-09-29 09:45:02 +08:00
parent 80222099ff
commit f4ad2f8612
15 changed files with 990 additions and 37 deletions
+172
View File
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../api/ai_api.dart';
import '../../../core/network/api_exception.dart';
import '../../../models/ai_persona.dart';
import '../../../providers/ai_persona_provider.dart';
import '../../../utils/toast.dart';
import '../../../widgets/avatar.dart';
import '../../../widgets/state_views.dart';
/// 人设列表页(不含 Scaffold/AppBar —— 它作为「AI 设置」页的一个 Tab 嵌进去)
@@ -154,6 +156,13 @@ class AiPersonaTab extends ConsumerWidget {
active ? const Color(0xFFC8956E) : const Color(0xFFD7CCC8),
),
),
const SizedBox(width: 8),
// 人设头像。没设过就是默认小人图标
Avatar(
assetPath: item.avatar,
size: 38,
fallbackIcon: 'assistant',
),
const SizedBox(width: 10),
Expanded(
child: Column(
@@ -319,6 +328,20 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
bool _saving = false;
/// 当前头像相对路径。null = 没有头像。
///
/// 三种状态要分清(和后端语义对齐):
/// · 进来时的原值 → 不传,保持不动
/// · 用户选了新图 → 传新路径
/// · 用户点了「移除头像」→ 传 clearAvatar=true
String? _avatar;
/// 是否显式要求清空头像
bool _clearAvatar = false;
/// 上传中(转圈 + 禁止重复点)
bool _uploading = false;
bool get _isEdit => widget.existing != null;
@override
@@ -331,6 +354,7 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
_prompt = TextEditingController(
text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '',
);
_avatar = e?.avatar;
}
@override
@@ -340,6 +364,82 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
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 {
final prompt = _prompt.text.trim();
if (prompt.isEmpty) {
@@ -350,16 +450,21 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
setState(() => _saving = true);
try {
final api = ref.read(aiApiProvider);
// 只有「选了新图」才传 avatar;没动就不传(后端保持原值)
final newAvatar = _clearAvatar ? null : _avatar;
if (_isEdit) {
await api.updatePersona(
id: widget.existing!.id!,
name: _name.text.trim(),
systemPrompt: prompt,
avatar: newAvatar,
clearAvatar: _clearAvatar,
);
} else {
await api.createPersona(
name: _name.text.trim(),
systemPrompt: prompt,
avatar: newAvatar,
);
}
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() {
final def = ref.read(defaultPersonaPromptProvider);
if (def == null || def.isEmpty) {
@@ -412,6 +580,10 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
),
const SizedBox(height: 18),
// 头像放最上面 —— 它是「这个人长什么样」的第一印象
Center(child: _avatarPicker()),
const SizedBox(height: 18),
_field(
label: '名称',
controller: _name,