AI助手优化-头像和对话功能
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user