feat: 角色卡 demo
This commit is contained in:
31
wei_ai_app/lib/core/config/supabase_config.dart
Normal file
31
wei_ai_app/lib/core/config/supabase_config.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Supabase 配置
|
||||
///
|
||||
/// 这里配置了本地 Docker 部署的 Supabase 连接信息
|
||||
/// 生产环境部署时需要替换为实际的 URL 和 Key
|
||||
class SupabaseConfig {
|
||||
SupabaseConfig._();
|
||||
|
||||
/// Supabase API URL
|
||||
///
|
||||
/// - Android 模拟器: 使用 10.0.2.2 访问主机
|
||||
/// - iOS 模拟器/macOS: 使用 localhost
|
||||
/// - 真机测试: 需要替换为电脑的局域网 IP 地址
|
||||
static String get url {
|
||||
// Android 模拟器需要使用 10.0.2.2 来访问主机
|
||||
if (Platform.isAndroid) {
|
||||
return 'http://10.0.2.2:8000';
|
||||
}
|
||||
// 其他平台使用 localhost
|
||||
return 'http://localhost:8000';
|
||||
}
|
||||
|
||||
/// Supabase Anon Key
|
||||
/// 这个 key 是公开的,用于客户端访问
|
||||
static const String anonKey =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY5NTk1NjI4LCJleHAiOjE5MjcyNzU2Mjh9.moV0JpCSx3Y1QTZmKZ5K-tQLaWcshxtxFlCoIBQFsEU';
|
||||
|
||||
/// 是否启用调试模式
|
||||
static const bool debug = true;
|
||||
}
|
||||
17
wei_ai_app/lib/core/core.dart
Normal file
17
wei_ai_app/lib/core/core.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
/// Core 模块导出
|
||||
///
|
||||
/// 统一导出所有 core 模块的公共 API
|
||||
library core;
|
||||
|
||||
// 配置
|
||||
export 'config/supabase_config.dart';
|
||||
|
||||
// 服务
|
||||
export 'services/services.dart';
|
||||
|
||||
// 模型
|
||||
export 'models/models.dart';
|
||||
|
||||
// 仓库
|
||||
export 'repositories/repositories.dart';
|
||||
|
||||
43
wei_ai_app/lib/core/models/category_model.dart
Normal file
43
wei_ai_app/lib/core/models/category_model.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
/// 分类/筛选模型
|
||||
///
|
||||
/// 对应 Supabase 中的 categories 表
|
||||
class CategoryModel {
|
||||
final String id;
|
||||
final String code;
|
||||
final String label;
|
||||
final int sortOrder;
|
||||
final bool isActive;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const CategoryModel({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.label,
|
||||
this.sortOrder = 0,
|
||||
this.isActive = true,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory CategoryModel.fromJson(Map<String, dynamic> json) {
|
||||
return CategoryModel(
|
||||
id: json['id'] as String,
|
||||
code: json['code'] as String,
|
||||
label: json['label'] as String,
|
||||
sortOrder: json['sort_order'] as int? ?? 0,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'code': code,
|
||||
'label': label,
|
||||
'sort_order': sortOrder,
|
||||
'is_active': isActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
281
wei_ai_app/lib/core/models/character_model.dart
Normal file
281
wei_ai_app/lib/core/models/character_model.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
/// AI 性格配置
|
||||
class AiPersonality {
|
||||
final double temperature;
|
||||
final List<String> traits;
|
||||
final String responseStyle;
|
||||
final List<String>? forbiddenTopics;
|
||||
final String? model;
|
||||
final int? maxTokens;
|
||||
|
||||
const AiPersonality({
|
||||
this.temperature = 0.7,
|
||||
this.traits = const [],
|
||||
this.responseStyle = '',
|
||||
this.forbiddenTopics,
|
||||
this.model,
|
||||
this.maxTokens,
|
||||
});
|
||||
|
||||
factory AiPersonality.fromJson(Map<String, dynamic> json) {
|
||||
return AiPersonality(
|
||||
temperature: (json['temperature'] as num?)?.toDouble() ?? 0.7,
|
||||
traits: (json['traits'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
responseStyle: json['response_style'] as String? ?? '',
|
||||
forbiddenTopics: (json['forbidden_topics'] as List<dynamic>?)?.cast<String>(),
|
||||
model: json['model'] as String?,
|
||||
maxTokens: json['max_tokens'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'temperature': temperature,
|
||||
'traits': traits,
|
||||
'response_style': responseStyle,
|
||||
if (forbiddenTopics != null) 'forbidden_topics': forbiddenTopics,
|
||||
if (model != null) 'model': model,
|
||||
if (maxTokens != null) 'max_tokens': maxTokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 语音配置
|
||||
class AiVoiceConfig {
|
||||
final String? voiceId;
|
||||
final double speed;
|
||||
final double pitch;
|
||||
final String? emotion;
|
||||
|
||||
const AiVoiceConfig({
|
||||
this.voiceId,
|
||||
this.speed = 1.0,
|
||||
this.pitch = 1.0,
|
||||
this.emotion,
|
||||
});
|
||||
|
||||
factory AiVoiceConfig.fromJson(Map<String, dynamic> json) {
|
||||
return AiVoiceConfig(
|
||||
voiceId: json['voice_id'] as String?,
|
||||
speed: (json['speed'] as num?)?.toDouble() ?? 1.0,
|
||||
pitch: (json['pitch'] as num?)?.toDouble() ?? 1.0,
|
||||
emotion: json['emotion'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
if (voiceId != null) 'voice_id': voiceId,
|
||||
'speed': speed,
|
||||
'pitch': pitch,
|
||||
if (emotion != null) 'emotion': emotion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 角色标签
|
||||
class CharacterTag {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? color;
|
||||
|
||||
const CharacterTag({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.color,
|
||||
});
|
||||
|
||||
factory CharacterTag.fromJson(Map<String, dynamic> json) {
|
||||
return CharacterTag(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
color: json['color'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
if (color != null) 'color': color,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 角色状态枚举
|
||||
enum CharacterStatus {
|
||||
online,
|
||||
busy,
|
||||
offline;
|
||||
|
||||
static CharacterStatus fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'online':
|
||||
return CharacterStatus.online;
|
||||
case 'busy':
|
||||
return CharacterStatus.busy;
|
||||
case 'offline':
|
||||
return CharacterStatus.offline;
|
||||
default:
|
||||
return CharacterStatus.offline;
|
||||
}
|
||||
}
|
||||
|
||||
String get value {
|
||||
switch (this) {
|
||||
case CharacterStatus.online:
|
||||
return 'online';
|
||||
case CharacterStatus.busy:
|
||||
return 'busy';
|
||||
case CharacterStatus.offline:
|
||||
return 'offline';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 角色模型
|
||||
///
|
||||
/// 对应 Supabase 中的 characters 表
|
||||
class CharacterModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? tagline;
|
||||
final String? avatarPath;
|
||||
final String? description;
|
||||
final CharacterStatus status;
|
||||
final bool isLocked;
|
||||
final bool isActive;
|
||||
final int sortOrder;
|
||||
|
||||
// AI 配置
|
||||
final String? aiSystemPrompt;
|
||||
final String? aiGreeting;
|
||||
final AiPersonality aiPersonality;
|
||||
final AiVoiceConfig aiVoiceConfig;
|
||||
|
||||
// 关联的标签
|
||||
final List<CharacterTag> tags;
|
||||
|
||||
// 时间戳
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const CharacterModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.tagline,
|
||||
this.avatarPath,
|
||||
this.description,
|
||||
this.status = CharacterStatus.offline,
|
||||
this.isLocked = false,
|
||||
this.isActive = true,
|
||||
this.sortOrder = 0,
|
||||
this.aiSystemPrompt,
|
||||
this.aiGreeting,
|
||||
this.aiPersonality = const AiPersonality(),
|
||||
this.aiVoiceConfig = const AiVoiceConfig(),
|
||||
this.tags = const [],
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
/// 从 JSON 创建(用于 Supabase 响应解析)
|
||||
factory CharacterModel.fromJson(Map<String, dynamic> json) {
|
||||
// 解析标签 - 可能来自视图的 jsonb 数组或单独查询
|
||||
List<CharacterTag> parseTags(dynamic tagsData) {
|
||||
if (tagsData == null) return [];
|
||||
if (tagsData is List) {
|
||||
return tagsData
|
||||
.where((t) => t != null && t is Map<String, dynamic>)
|
||||
.map((t) => CharacterTag.fromJson(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return CharacterModel(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
tagline: json['tagline'] as String?,
|
||||
avatarPath: json['avatar_path'] as String?,
|
||||
description: json['description'] as String?,
|
||||
status: CharacterStatus.fromString(json['status'] as String?),
|
||||
isLocked: json['is_locked'] as bool? ?? false,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
sortOrder: json['sort_order'] as int? ?? 0,
|
||||
aiSystemPrompt: json['ai_system_prompt'] as String?,
|
||||
aiGreeting: json['ai_greeting'] as String?,
|
||||
aiPersonality: json['ai_personality'] != null
|
||||
? AiPersonality.fromJson(json['ai_personality'] as Map<String, dynamic>)
|
||||
: const AiPersonality(),
|
||||
aiVoiceConfig: json['ai_voice_config'] != null
|
||||
? AiVoiceConfig.fromJson(json['ai_voice_config'] as Map<String, dynamic>)
|
||||
: const AiVoiceConfig(),
|
||||
tags: parseTags(json['tags']),
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: null,
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为 JSON(用于插入/更新)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'tagline': tagline,
|
||||
'avatar_path': avatarPath,
|
||||
'description': description,
|
||||
'status': status.value,
|
||||
'is_locked': isLocked,
|
||||
'is_active': isActive,
|
||||
'sort_order': sortOrder,
|
||||
'ai_system_prompt': aiSystemPrompt,
|
||||
'ai_greeting': aiGreeting,
|
||||
'ai_personality': aiPersonality.toJson(),
|
||||
'ai_voice_config': aiVoiceConfig.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取标签名称列表(用于兼容旧代码)
|
||||
List<String> get tagNames => tags.map((t) => t.name).toList();
|
||||
|
||||
/// 复制并修改部分字段
|
||||
CharacterModel copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? tagline,
|
||||
String? avatarPath,
|
||||
String? description,
|
||||
CharacterStatus? status,
|
||||
bool? isLocked,
|
||||
bool? isActive,
|
||||
int? sortOrder,
|
||||
String? aiSystemPrompt,
|
||||
String? aiGreeting,
|
||||
AiPersonality? aiPersonality,
|
||||
AiVoiceConfig? aiVoiceConfig,
|
||||
List<CharacterTag>? tags,
|
||||
}) {
|
||||
return CharacterModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tagline: tagline ?? this.tagline,
|
||||
avatarPath: avatarPath ?? this.avatarPath,
|
||||
description: description ?? this.description,
|
||||
status: status ?? this.status,
|
||||
isLocked: isLocked ?? this.isLocked,
|
||||
isActive: isActive ?? this.isActive,
|
||||
sortOrder: sortOrder ?? this.sortOrder,
|
||||
aiSystemPrompt: aiSystemPrompt ?? this.aiSystemPrompt,
|
||||
aiGreeting: aiGreeting ?? this.aiGreeting,
|
||||
aiPersonality: aiPersonality ?? this.aiPersonality,
|
||||
aiVoiceConfig: aiVoiceConfig ?? this.aiVoiceConfig,
|
||||
tags: tags ?? this.tags,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
127
wei_ai_app/lib/core/models/chat_message_model.dart
Normal file
127
wei_ai_app/lib/core/models/chat_message_model.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
/// 聊天消息模型
|
||||
|
||||
/// 聊天消息模型
|
||||
class ChatMessage {
|
||||
final String id;
|
||||
final String role; // 'user', 'assistant', 'system'
|
||||
final String content;
|
||||
final DateTime timestamp;
|
||||
|
||||
const ChatMessage({
|
||||
required this.id,
|
||||
required this.role,
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) {
|
||||
return ChatMessage(
|
||||
id: json['id'] as String,
|
||||
role: json['role'] as String,
|
||||
content: json['content'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'role': role,
|
||||
'content': content,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 转换为 OpenAI API 格式
|
||||
Map<String, String> toApiFormat() {
|
||||
return {
|
||||
'role': role,
|
||||
'content': content,
|
||||
};
|
||||
}
|
||||
|
||||
/// 是否是用户消息
|
||||
bool get isUser => role == 'user';
|
||||
|
||||
/// 是否是 AI 消息
|
||||
bool get isAssistant => role == 'assistant';
|
||||
|
||||
/// 创建用户消息
|
||||
factory ChatMessage.user(String content) {
|
||||
return ChatMessage(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
role: 'user',
|
||||
content: content,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建 AI 消息
|
||||
factory ChatMessage.assistant(String content) {
|
||||
return ChatMessage(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建系统消息
|
||||
factory ChatMessage.system(String content) {
|
||||
return ChatMessage(
|
||||
id: 'system',
|
||||
role: 'system',
|
||||
content: content,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 聊天会话模型
|
||||
class ChatSession {
|
||||
final String characterId;
|
||||
final List<ChatMessage> messages;
|
||||
final DateTime lastUpdated;
|
||||
|
||||
const ChatSession({
|
||||
required this.characterId,
|
||||
required this.messages,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
|
||||
factory ChatSession.fromJson(Map<String, dynamic> json) {
|
||||
return ChatSession(
|
||||
characterId: json['character_id'] as String,
|
||||
messages: (json['messages'] as List)
|
||||
.map((m) => ChatMessage.fromJson(m))
|
||||
.toList(),
|
||||
lastUpdated: DateTime.parse(json['last_updated'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'character_id': characterId,
|
||||
'messages': messages.map((m) => m.toJson()).toList(),
|
||||
'last_updated': lastUpdated.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 创建空会话
|
||||
factory ChatSession.empty(String characterId) {
|
||||
return ChatSession(
|
||||
characterId: characterId,
|
||||
messages: [],
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 添加消息并返回新会话
|
||||
ChatSession addMessage(ChatMessage message) {
|
||||
return ChatSession(
|
||||
characterId: characterId,
|
||||
messages: [...messages, message],
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
73
wei_ai_app/lib/core/models/llm_config_model.dart
Normal file
73
wei_ai_app/lib/core/models/llm_config_model.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
/// LLM 配置模型
|
||||
///
|
||||
/// 对应 Supabase 中的 llm_config 表
|
||||
class LlmConfigModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final bool isActive;
|
||||
|
||||
// API 连接
|
||||
final String apiBaseUrl;
|
||||
final String apiKey;
|
||||
final String model;
|
||||
|
||||
// 模型参数
|
||||
final double temperature;
|
||||
final int maxTokens;
|
||||
final bool stream;
|
||||
|
||||
// 时间戳
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const LlmConfigModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.isActive = true,
|
||||
required this.apiBaseUrl,
|
||||
required this.apiKey,
|
||||
required this.model,
|
||||
this.temperature = 0.7,
|
||||
this.maxTokens = 2048,
|
||||
this.stream = true,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory LlmConfigModel.fromJson(Map<String, dynamic> json) {
|
||||
return LlmConfigModel(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
apiBaseUrl: json['api_base_url'] as String,
|
||||
apiKey: json['api_key'] as String,
|
||||
model: json['model'] as String,
|
||||
temperature: (json['temperature'] as num?)?.toDouble() ?? 0.7,
|
||||
maxTokens: json['max_tokens'] as int? ?? 2048,
|
||||
stream: json['stream'] as bool? ?? true,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: null,
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'is_active': isActive,
|
||||
'api_base_url': apiBaseUrl,
|
||||
'api_key': apiKey,
|
||||
'model': model,
|
||||
'temperature': temperature,
|
||||
'max_tokens': maxTokens,
|
||||
'stream': stream,
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取完整的 API URL(用于聊天完成)
|
||||
String get chatCompletionsUrl => '$apiBaseUrl/chat/completions';
|
||||
}
|
||||
7
wei_ai_app/lib/core/models/models.dart
Normal file
7
wei_ai_app/lib/core/models/models.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
/// Core Models 导出
|
||||
library models;
|
||||
|
||||
export 'character_model.dart';
|
||||
export 'category_model.dart';
|
||||
export 'llm_config_model.dart';
|
||||
export 'chat_message_model.dart';
|
||||
164
wei_ai_app/lib/core/repositories/character_repository.dart
Normal file
164
wei_ai_app/lib/core/repositories/character_repository.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/supabase_service.dart';
|
||||
import '../models/character_model.dart';
|
||||
import '../models/category_model.dart';
|
||||
|
||||
/// 角色仓库
|
||||
///
|
||||
/// 负责从 Supabase 获取角色相关数据
|
||||
class CharacterRepository {
|
||||
CharacterRepository._();
|
||||
|
||||
/// 获取所有分类
|
||||
static Future<List<CategoryModel>> getCategories() async {
|
||||
try {
|
||||
final response = await SupabaseService.from('categories')
|
||||
.select()
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', ascending: true);
|
||||
|
||||
return (response as List)
|
||||
.map((json) => CategoryModel.fromJson(json))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ 获取分类失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有角色(包含标签)
|
||||
///
|
||||
/// 使用 characters_with_tags 视图获取完整数据
|
||||
static Future<List<CharacterModel>> getCharacters() async {
|
||||
try {
|
||||
final response = await SupabaseService.from('characters_with_tags')
|
||||
.select();
|
||||
|
||||
return (response as List)
|
||||
.map((json) => CharacterModel.fromJson(json))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ 获取角色列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据分类筛选角色
|
||||
///
|
||||
/// [categoryCode] 分类代码,如 'gentle', 'dom' 等
|
||||
/// 'all' 返回所有角色
|
||||
static Future<List<CharacterModel>> getCharactersByCategory(String categoryCode) async {
|
||||
try {
|
||||
// 如果是 'all',直接获取所有
|
||||
if (categoryCode == 'all') {
|
||||
return getCharacters();
|
||||
}
|
||||
|
||||
// 获取该分类下的标签
|
||||
final tagsResponse = await SupabaseService.from('tags')
|
||||
.select('name')
|
||||
.eq('category_id',
|
||||
SupabaseService.from('categories')
|
||||
.select('id')
|
||||
.eq('code', categoryCode)
|
||||
.single()
|
||||
);
|
||||
|
||||
final tagNames = (tagsResponse as List)
|
||||
.map((t) => t['name'] as String)
|
||||
.toList();
|
||||
|
||||
if (tagNames.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取包含这些标签的角色
|
||||
final allCharacters = await getCharacters();
|
||||
|
||||
return allCharacters.where((char) {
|
||||
return char.tagNames.any((tag) => tagNames.contains(tag));
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ 按分类获取角色失败: $e');
|
||||
// 如果查询失败,返回本地筛选
|
||||
return _filterCharactersLocally(await getCharacters(), categoryCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地筛选角色(作为后备方案)
|
||||
static List<CharacterModel> _filterCharactersLocally(
|
||||
List<CharacterModel> characters,
|
||||
String categoryCode
|
||||
) {
|
||||
if (categoryCode == 'all') return characters;
|
||||
|
||||
return characters.where((c) {
|
||||
final tags = c.tagNames.join('');
|
||||
switch (categoryCode) {
|
||||
case 'gentle':
|
||||
return tags.contains('治愈') || tags.contains('温顺') || tags.contains('医疗');
|
||||
case 'dom':
|
||||
return tags.contains('强势') || tags.contains('调教') || tags.contains('指令');
|
||||
case 'wild':
|
||||
return tags.contains('病娇') || tags.contains('神秘') ||
|
||||
tags.contains('不稳定') || tags.contains('极乐');
|
||||
case 'exclusive':
|
||||
return c.isLocked;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 根据 ID 获取单个角色
|
||||
static Future<CharacterModel?> getCharacterById(String id) async {
|
||||
try {
|
||||
final response = await SupabaseService.from('characters_with_tags')
|
||||
.select()
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (response == null) return null;
|
||||
return CharacterModel.fromJson(response);
|
||||
} catch (e) {
|
||||
debugPrint('❌ 获取角色详情失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取角色头像的公开 URL
|
||||
///
|
||||
/// [avatarPath] Storage 中的路径
|
||||
static String getAvatarUrl(String? avatarPath) {
|
||||
if (avatarPath == null || avatarPath.isEmpty) {
|
||||
// 返回默认头像
|
||||
return 'https://via.placeholder.com/300x400?text=No+Avatar';
|
||||
}
|
||||
|
||||
// 如果已经是完整 URL,直接返回
|
||||
if (avatarPath.startsWith('http')) {
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
// 从 Storage 获取公开 URL
|
||||
return SupabaseService.storage
|
||||
.from('avatars')
|
||||
.getPublicUrl(avatarPath);
|
||||
}
|
||||
|
||||
/// 获取仅会员可见的角色
|
||||
static Future<List<CharacterModel>> getLockedCharacters() async {
|
||||
try {
|
||||
final response = await SupabaseService.from('characters_with_tags')
|
||||
.select()
|
||||
.eq('is_locked', true);
|
||||
|
||||
return (response as List)
|
||||
.map((json) => CharacterModel.fromJson(json))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ 获取会员角色失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
wei_ai_app/lib/core/repositories/llm_config_repository.dart
Normal file
60
wei_ai_app/lib/core/repositories/llm_config_repository.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/supabase_service.dart';
|
||||
import '../models/llm_config_model.dart';
|
||||
|
||||
/// LLM 配置仓库
|
||||
///
|
||||
/// 负责从 Supabase 获取 LLM 配置
|
||||
class LlmConfigRepository {
|
||||
LlmConfigRepository._();
|
||||
|
||||
static LlmConfigModel? _cachedConfig;
|
||||
|
||||
/// 获取当前激活的 LLM 配置
|
||||
///
|
||||
/// 开发阶段默认强制刷新,生产环境可改为 false 使用缓存
|
||||
static Future<LlmConfigModel?> getActiveConfig({bool forceRefresh = true}) async {
|
||||
if (_cachedConfig != null && !forceRefresh) {
|
||||
return _cachedConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await SupabaseService.from('llm_config')
|
||||
.select()
|
||||
.eq('is_active', true)
|
||||
.maybeSingle();
|
||||
|
||||
if (response == null) {
|
||||
debugPrint('⚠️ 没有找到激活的 LLM 配置');
|
||||
return null;
|
||||
}
|
||||
|
||||
_cachedConfig = LlmConfigModel.fromJson(response);
|
||||
debugPrint('✅ LLM 配置已加载: ${_cachedConfig!.model}');
|
||||
return _cachedConfig;
|
||||
} catch (e) {
|
||||
debugPrint('❌ 获取 LLM 配置失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除缓存
|
||||
static void clearCache() {
|
||||
_cachedConfig = null;
|
||||
}
|
||||
|
||||
/// 更新配置(需要管理员权限)
|
||||
static Future<void> updateConfig(LlmConfigModel config) async {
|
||||
try {
|
||||
await SupabaseService.from('llm_config')
|
||||
.update(config.toJson())
|
||||
.eq('id', config.id);
|
||||
|
||||
_cachedConfig = config;
|
||||
debugPrint('✅ LLM 配置已更新');
|
||||
} catch (e) {
|
||||
debugPrint('❌ 更新 LLM 配置失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
wei_ai_app/lib/core/repositories/repositories.dart
Normal file
5
wei_ai_app/lib/core/repositories/repositories.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
/// Core Repositories 导出
|
||||
library repositories;
|
||||
|
||||
export 'character_repository.dart';
|
||||
export 'llm_config_repository.dart';
|
||||
183
wei_ai_app/lib/core/services/chat_service.dart
Normal file
183
wei_ai_app/lib/core/services/chat_service.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/chat_message_model.dart';
|
||||
import '../models/llm_config_model.dart';
|
||||
import '../models/character_model.dart';
|
||||
import '../repositories/llm_config_repository.dart';
|
||||
|
||||
/// AI 聊天服务
|
||||
///
|
||||
/// 负责调用 LLM API 进行对话
|
||||
class ChatService {
|
||||
ChatService._();
|
||||
|
||||
/// 发送消息并获取 AI 回复
|
||||
///
|
||||
/// [character] 当前对话的角色
|
||||
/// [messages] 历史消息列表
|
||||
/// [userMessage] 用户新发送的消息
|
||||
/// [onStream] 流式输出回调(每次收到新内容时调用)
|
||||
static Future<String> sendMessage({
|
||||
required CharacterModel character,
|
||||
required List<ChatMessage> messages,
|
||||
required String userMessage,
|
||||
Function(String)? onStream,
|
||||
}) async {
|
||||
// 获取 LLM 配置
|
||||
final config = await LlmConfigRepository.getActiveConfig();
|
||||
if (config == null) {
|
||||
throw Exception('LLM 配置未找到,请先配置 API Key');
|
||||
}
|
||||
|
||||
// 构建消息列表
|
||||
final apiMessages = <Map<String, String>>[];
|
||||
|
||||
// 1. System prompt(角色人设)
|
||||
final systemPrompt = character.aiSystemPrompt ??
|
||||
'你是 ${character.name},${character.tagline ?? "一个AI角色"}。${character.description ?? ""}';
|
||||
apiMessages.add({'role': 'system', 'content': systemPrompt});
|
||||
|
||||
// 2. 历史消息(最多保留最近 20 条)
|
||||
final recentMessages = messages.length > 20
|
||||
? messages.sublist(messages.length - 20)
|
||||
: messages;
|
||||
for (final msg in recentMessages) {
|
||||
if (msg.role != 'system') {
|
||||
apiMessages.add(msg.toApiFormat());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 新用户消息
|
||||
apiMessages.add({'role': 'user', 'content': userMessage});
|
||||
|
||||
debugPrint('📤 发送到 LLM: ${apiMessages.length} 条消息');
|
||||
|
||||
// 判断是否使用流式输出
|
||||
if (config.stream && onStream != null) {
|
||||
return _sendStreamRequest(config, apiMessages, onStream);
|
||||
} else {
|
||||
return _sendNormalRequest(config, apiMessages);
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通请求(非流式)
|
||||
static Future<String> _sendNormalRequest(
|
||||
LlmConfigModel config,
|
||||
List<Map<String, String>> messages,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse(config.chatCompletionsUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${config.apiKey}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'model': config.model,
|
||||
'messages': messages,
|
||||
'temperature': config.temperature,
|
||||
'max_tokens': config.maxTokens,
|
||||
'stream': false,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('❌ LLM API 错误: ${response.statusCode} ${response.body}');
|
||||
throw Exception('API 请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
final content = data['choices'][0]['message']['content'] as String;
|
||||
debugPrint('📥 收到回复: ${content.substring(0, content.length.clamp(0, 50))}...');
|
||||
return content;
|
||||
} catch (e) {
|
||||
debugPrint('❌ LLM 请求失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式请求
|
||||
static Future<String> _sendStreamRequest(
|
||||
LlmConfigModel config,
|
||||
List<Map<String, String>> messages,
|
||||
Function(String) onStream,
|
||||
) async {
|
||||
try {
|
||||
final client = http.Client();
|
||||
final request = http.Request('POST', Uri.parse(config.chatCompletionsUrl));
|
||||
|
||||
request.headers.addAll({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${config.apiKey}',
|
||||
'Accept': 'text/event-stream',
|
||||
});
|
||||
|
||||
request.body = jsonEncode({
|
||||
'model': config.model,
|
||||
'messages': messages,
|
||||
'temperature': config.temperature,
|
||||
'max_tokens': config.maxTokens,
|
||||
'stream': true,
|
||||
});
|
||||
|
||||
final response = await client.send(request);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final body = await response.stream.bytesToString();
|
||||
debugPrint('❌ LLM API 错误: ${response.statusCode} $body');
|
||||
throw Exception('API 请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final completer = Completer<String>();
|
||||
final buffer = StringBuffer();
|
||||
|
||||
response.stream
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
(line) {
|
||||
if (line.startsWith('data: ')) {
|
||||
final data = line.substring(6);
|
||||
if (data == '[DONE]') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final json = jsonDecode(data);
|
||||
final delta = json['choices']?[0]?['delta']?['content'];
|
||||
if (delta != null && delta is String) {
|
||||
buffer.write(delta);
|
||||
onStream(buffer.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
client.close();
|
||||
final result = buffer.toString();
|
||||
debugPrint('📥 流式回复完成: ${result.length} 字符');
|
||||
completer.complete(result);
|
||||
},
|
||||
onError: (e) {
|
||||
client.close();
|
||||
debugPrint('❌ 流式请求错误: $e');
|
||||
completer.completeError(e);
|
||||
},
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
} catch (e) {
|
||||
debugPrint('❌ LLM 流式请求失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取角色的问候语
|
||||
static String getGreeting(CharacterModel character) {
|
||||
return character.aiGreeting ??
|
||||
'你好,我是 ${character.name}。有什么我可以帮你的吗?';
|
||||
}
|
||||
}
|
||||
78
wei_ai_app/lib/core/services/chat_storage_service.dart
Normal file
78
wei_ai_app/lib/core/services/chat_storage_service.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/chat_message_model.dart';
|
||||
|
||||
/// 本地聊天存储服务
|
||||
///
|
||||
/// 使用 SharedPreferences 存储聊天记录
|
||||
class ChatStorageService {
|
||||
ChatStorageService._();
|
||||
|
||||
static const String _keyPrefix = 'chat_session_';
|
||||
|
||||
/// 获取聊天会话
|
||||
static Future<ChatSession> getSession(String characterId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = '$_keyPrefix$characterId';
|
||||
final data = prefs.getString(key);
|
||||
|
||||
if (data == null) {
|
||||
return ChatSession.empty(characterId);
|
||||
}
|
||||
|
||||
return ChatSession.fromJson(jsonDecode(data));
|
||||
} catch (e) {
|
||||
debugPrint('❌ 读取聊天记录失败: $e');
|
||||
return ChatSession.empty(characterId);
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存聊天会话
|
||||
static Future<void> saveSession(ChatSession session) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = '$_keyPrefix${session.characterId}';
|
||||
await prefs.setString(key, jsonEncode(session.toJson()));
|
||||
debugPrint('✅ 聊天记录已保存 (${session.messages.length} 条消息)');
|
||||
} catch (e) {
|
||||
debugPrint('❌ 保存聊天记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加消息到会话
|
||||
static Future<ChatSession> addMessage(String characterId, ChatMessage message) async {
|
||||
final session = await getSession(characterId);
|
||||
final newSession = session.addMessage(message);
|
||||
await saveSession(newSession);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
/// 清除角色的聊天记录
|
||||
static Future<void> clearSession(String characterId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = '$_keyPrefix$characterId';
|
||||
await prefs.remove(key);
|
||||
debugPrint('✅ 聊天记录已清除');
|
||||
} catch (e) {
|
||||
debugPrint('❌ 清除聊天记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有聊天会话的角色 ID 列表
|
||||
static Future<List<String>> getAllSessionIds() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keys = prefs.getKeys();
|
||||
return keys
|
||||
.where((k) => k.startsWith(_keyPrefix))
|
||||
.map((k) => k.substring(_keyPrefix.length))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ 获取会话列表失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
6
wei_ai_app/lib/core/services/services.dart
Normal file
6
wei_ai_app/lib/core/services/services.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
/// Core Services 导出
|
||||
library services;
|
||||
|
||||
export 'supabase_service.dart';
|
||||
export 'chat_service.dart';
|
||||
export 'chat_storage_service.dart';
|
||||
60
wei_ai_app/lib/core/services/supabase_service.dart
Normal file
60
wei_ai_app/lib/core/services/supabase_service.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../config/supabase_config.dart';
|
||||
|
||||
/// Supabase 服务
|
||||
///
|
||||
/// 负责初始化和管理 Supabase 客户端
|
||||
class SupabaseService {
|
||||
SupabaseService._();
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 初始化 Supabase
|
||||
///
|
||||
/// 应该在 main() 函数中调用,确保在 runApp 之前完成
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
debugPrint('⚠️ Supabase 已经初始化过了');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Supabase.initialize(
|
||||
url: SupabaseConfig.url,
|
||||
anonKey: SupabaseConfig.anonKey,
|
||||
debug: SupabaseConfig.debug,
|
||||
);
|
||||
_initialized = true;
|
||||
debugPrint('✅ Supabase 初始化成功');
|
||||
debugPrint(' URL: ${SupabaseConfig.url}');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Supabase 初始化失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Supabase 客户端实例
|
||||
static SupabaseClient get client => Supabase.instance.client;
|
||||
|
||||
/// 获取当前登录用户
|
||||
static User? get currentUser => client.auth.currentUser;
|
||||
|
||||
/// 检查用户是否已登录
|
||||
static bool get isLoggedIn => currentUser != null;
|
||||
|
||||
/// 获取 Auth 实例
|
||||
static GoTrueClient get auth => client.auth;
|
||||
|
||||
/// 获取数据库实例
|
||||
static SupabaseQueryBuilder from(String table) => client.from(table);
|
||||
|
||||
/// 获取存储实例
|
||||
static SupabaseStorageClient get storage => client.storage;
|
||||
|
||||
/// 获取 Realtime 实例
|
||||
static RealtimeClient get realtime => client.realtime;
|
||||
|
||||
/// 获取 Functions 实例
|
||||
static FunctionsClient get functions => client.functions;
|
||||
}
|
||||
@@ -2,8 +2,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'config/theme.dart';
|
||||
import 'router/app_router.dart';
|
||||
import 'core/services/supabase_service.dart';
|
||||
|
||||
void main() {
|
||||
void main() async {
|
||||
// 确保 Flutter 绑定初始化
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 初始化 Supabase
|
||||
await SupabaseService.initialize();
|
||||
|
||||
runApp(const ProviderScope(child: WeiAiApp()));
|
||||
}
|
||||
|
||||
|
||||
68
wei_ai_app/lib/providers/character_providers.dart
Normal file
68
wei_ai_app/lib/providers/character_providers.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/core.dart';
|
||||
|
||||
/// 分类列表 Provider
|
||||
final categoriesProvider = FutureProvider<List<CategoryModel>>((ref) async {
|
||||
return CharacterRepository.getCategories();
|
||||
});
|
||||
|
||||
/// 角色列表 Provider
|
||||
final charactersProvider = FutureProvider<List<CharacterModel>>((ref) async {
|
||||
return CharacterRepository.getCharacters();
|
||||
});
|
||||
|
||||
/// 当前选中的分类代码 Notifier
|
||||
class SelectedCategoryNotifier extends Notifier<String> {
|
||||
@override
|
||||
String build() => 'all';
|
||||
|
||||
void setCategory(String code) {
|
||||
state = code;
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前选中的分类代码
|
||||
final selectedCategoryProvider = NotifierProvider<SelectedCategoryNotifier, String>(
|
||||
SelectedCategoryNotifier.new,
|
||||
);
|
||||
|
||||
/// 根据分类筛选后的角色列表
|
||||
final filteredCharactersProvider = Provider<AsyncValue<List<CharacterModel>>>((ref) {
|
||||
final categoryCode = ref.watch(selectedCategoryProvider);
|
||||
final charactersAsync = ref.watch(charactersProvider);
|
||||
|
||||
return charactersAsync.when(
|
||||
data: (characters) {
|
||||
if (categoryCode == 'all') {
|
||||
return AsyncValue.data(characters);
|
||||
}
|
||||
|
||||
// 本地筛选
|
||||
final filtered = characters.where((c) {
|
||||
final tags = c.tagNames.join('');
|
||||
switch (categoryCode) {
|
||||
case 'gentle':
|
||||
return tags.contains('治愈') || tags.contains('温顺') || tags.contains('医疗');
|
||||
case 'dom':
|
||||
return tags.contains('强势') || tags.contains('调教') || tags.contains('指令');
|
||||
case 'wild':
|
||||
return tags.contains('病娇') || tags.contains('神秘') ||
|
||||
tags.contains('不稳定') || tags.contains('极乐');
|
||||
case 'exclusive':
|
||||
return c.isLocked;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
|
||||
return AsyncValue.data(filtered);
|
||||
},
|
||||
loading: () => const AsyncValue.loading(),
|
||||
error: (e, st) => AsyncValue.error(e, st),
|
||||
);
|
||||
});
|
||||
|
||||
/// 单个角色详情 Provider
|
||||
final characterDetailProvider = FutureProvider.family<CharacterModel?, String>((ref, id) async {
|
||||
return CharacterRepository.getCharacterById(id);
|
||||
});
|
||||
4
wei_ai_app/lib/providers/providers.dart
Normal file
4
wei_ai_app/lib/providers/providers.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
/// Providers 导出
|
||||
library providers;
|
||||
|
||||
export 'character_providers.dart';
|
||||
@@ -1,126 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../data/mock_data.dart';
|
||||
import '../../models/character.dart';
|
||||
import '../../core/core.dart';
|
||||
import '../../providers/providers.dart';
|
||||
import '../../widgets/tab_content_layout.dart';
|
||||
|
||||
class DiscoveryScreen extends StatefulWidget {
|
||||
class DiscoveryScreen extends ConsumerWidget {
|
||||
const DiscoveryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
||||
}
|
||||
|
||||
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
String _activeFilter = 'all';
|
||||
|
||||
final List<Map<String, String>> _filters = [
|
||||
{'id': 'all', 'label': '全部'},
|
||||
{'id': 'gentle', 'label': '温柔治愈'},
|
||||
{'id': 'dom', 'label': '主导强势'},
|
||||
{'id': 'wild', 'label': '反差/猎奇'},
|
||||
{'id': 'voice', 'label': '语音陪聊'},
|
||||
{'id': 'scenario', 'label': '场景扮演'},
|
||||
{'id': 'exclusive', 'label': '会员限定'},
|
||||
];
|
||||
|
||||
List<Character> get _filteredCharacters {
|
||||
if (_activeFilter == 'all') return mockCharacters;
|
||||
return mockCharacters.where((c) {
|
||||
final tags = c.tags.join('');
|
||||
if (_activeFilter == 'gentle') return tags.contains('治愈') || tags.contains('温顺') || tags.contains('医疗');
|
||||
if (_activeFilter == 'dom') return tags.contains('强势') || tags.contains('调教') || tags.contains('指令');
|
||||
if (_activeFilter == 'wild') return tags.contains('病娇') || tags.contains('神秘') || tags.contains('不稳定') || tags.contains('极乐');
|
||||
if (_activeFilter == 'exclusive') return c.isLocked;
|
||||
return false;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double bottomNavHeight = 90;
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
final filteredCharactersAsync = ref.watch(filteredCharactersProvider);
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
|
||||
const double bottomNavHeight = 90;
|
||||
|
||||
return TabContentLayout(
|
||||
child: Column(
|
||||
children: [
|
||||
// 1. Sticky Filter Bar (simulated)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _filters.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final filter = _filters[index];
|
||||
final isActive = _activeFilter == filter['id'];
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _activeFilter = filter['id']!),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.white : Colors.white.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isActive ? Colors.white : Colors.white.withOpacity(0.1),
|
||||
children: [
|
||||
// 1. Filter Bar
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: categoriesAsync.when(
|
||||
data: (categories) => ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: categories.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final isActive = selectedCategory == category.code;
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onTap: () => ref.read(selectedCategoryProvider.notifier).setCategory(category.code),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.white : Colors.white.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isActive ? Colors.white : Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
category.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? const Color(0xFF2E1065) : Colors.white.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
filter['label']!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? const Color(0xFF2E1065) : Colors.white.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
error: (e, _) => Center(child: Text('加载分类失败', style: TextStyle(color: Colors.white.withOpacity(0.5)))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Grid Layout
|
||||
Expanded(
|
||||
child: _filteredCharacters.isEmpty
|
||||
? Center(child: Text('暂无匹配角色', style: TextStyle(color: Colors.white.withOpacity(0.5))))
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, bottomNavHeight + 20),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 3 / 4,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: _filteredCharacters.length,
|
||||
itemBuilder: (context, index) {
|
||||
final char = _filteredCharacters[index];
|
||||
return _CharacterCard(
|
||||
character: char,
|
||||
onTap: () {
|
||||
if (!char.isLocked) {
|
||||
// 2. Grid Layout
|
||||
Expanded(
|
||||
child: filteredCharactersAsync.when(
|
||||
data: (characters) => characters.isEmpty
|
||||
? Center(child: Text('暂无匹配角色', style: TextStyle(color: Colors.white.withOpacity(0.5))))
|
||||
: GridView.builder(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomNavHeight + 20),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 3 / 4,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: characters.length,
|
||||
itemBuilder: (context, index) {
|
||||
final char = characters[index];
|
||||
return _CharacterCard(
|
||||
character: char,
|
||||
onTap: () {
|
||||
if (!char.isLocked) {
|
||||
context.push('/interaction/${char.id}');
|
||||
}
|
||||
},
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 400.ms, delay: (index * 100).ms)
|
||||
.scale(begin: const Offset(0.9, 0.9));
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 400.ms, delay: (index * 100).ms)
|
||||
.scale(begin: const Offset(0.9, 0.9));
|
||||
},
|
||||
),
|
||||
loading: () => const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
SizedBox(height: 16),
|
||||
Text('正在加载角色...', style: TextStyle(color: Colors.white54)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.alertCircle, color: Colors.red.withOpacity(0.7), size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text('加载失败', style: TextStyle(color: Colors.white.withOpacity(0.7))),
|
||||
const SizedBox(height: 8),
|
||||
Text(e.toString(), style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 12)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.invalidate(charactersProvider),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CharacterCard extends StatelessWidget {
|
||||
final Character character;
|
||||
final CharacterModel character;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CharacterCard({
|
||||
@@ -130,6 +139,9 @@ class _CharacterCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 获取头像 URL
|
||||
final avatarUrl = CharacterRepository.getAvatarUrl(character.avatarPath);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
@@ -151,7 +163,7 @@ class _CharacterCard extends StatelessWidget {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Clipped content area - ensures all elements respect border radius
|
||||
// Clipped content area
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Stack(
|
||||
@@ -159,7 +171,7 @@ class _CharacterCard extends StatelessWidget {
|
||||
children: [
|
||||
// Background Image
|
||||
Image.network(
|
||||
character.avatar,
|
||||
avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
@@ -168,6 +180,22 @@ class _CharacterCard extends StatelessWidget {
|
||||
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: const Color(0xFF2E1065),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.user, size: 48, color: Colors.white.withOpacity(0.3)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
character.name,
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Gradient Overlay
|
||||
@@ -179,44 +207,13 @@ class _CharacterCard extends StatelessWidget {
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
Color(0xFF2E1065), // Deep purple at bottom
|
||||
Color(0xFF2E1065),
|
||||
],
|
||||
stops: [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Top Left: Popularity/Compatibility Badge
|
||||
if (!character.isLocked)
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.flame, size: 12, color: Color(0xFFF472B6)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${character.compatibility}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Top Right: Lock Icon
|
||||
if (character.isLocked)
|
||||
Positioned(
|
||||
@@ -270,7 +267,7 @@ class _CharacterCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
tag.name,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
);
|
||||
@@ -284,7 +281,7 @@ class _CharacterCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Border Overlay - Outside ClipRRect to ensure full visibility
|
||||
// Border Overlay
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
|
||||
@@ -2,230 +2,515 @@ import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../models/character.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../data/mock_data.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/core.dart';
|
||||
import 'voice_mode_overlay.dart';
|
||||
|
||||
class InteractionScreen extends StatefulWidget {
|
||||
class InteractionScreen extends ConsumerStatefulWidget {
|
||||
final String characterId;
|
||||
|
||||
const InteractionScreen({super.key, required this.characterId});
|
||||
|
||||
@override
|
||||
State<InteractionScreen> createState() => _InteractionScreenState();
|
||||
ConsumerState<InteractionScreen> createState() => _InteractionScreenState();
|
||||
}
|
||||
|
||||
class _InteractionScreenState extends State<InteractionScreen> {
|
||||
late Character _character;
|
||||
final List<Message> _messages = List.from(mockMessages);
|
||||
class _InteractionScreenState extends ConsumerState<InteractionScreen> {
|
||||
CharacterModel? _character;
|
||||
List<ChatMessage> _messages = [];
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isVoiceMode = false;
|
||||
bool _isLoading = false;
|
||||
bool _isTyping = false;
|
||||
String _typingContent = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_character = mockCharacters.firstWhere(
|
||||
(c) => c.id == widget.characterId,
|
||||
orElse: () => mockCharacters.first,
|
||||
);
|
||||
_loadCharacterAndMessages();
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
if (_controller.text.trim().isEmpty) return;
|
||||
|
||||
final newUserMsg = Message(
|
||||
id: DateTime.now().toString(),
|
||||
text: _controller.text,
|
||||
sender: MessageSender.user,
|
||||
type: MessageType.text,
|
||||
timestamp: DateTime.now()
|
||||
);
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadCharacterAndMessages() async {
|
||||
// 加载角色信息
|
||||
final character = await CharacterRepository.getCharacterById(widget.characterId);
|
||||
if (character == null) {
|
||||
debugPrint('❌ 角色不存在: ${widget.characterId}');
|
||||
if (mounted) context.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载本地聊天记录
|
||||
final session = await ChatStorageService.getSession(widget.characterId);
|
||||
|
||||
setState(() {
|
||||
_messages.add(newUserMsg);
|
||||
_controller.clear();
|
||||
_character = character;
|
||||
_messages = session.messages;
|
||||
});
|
||||
|
||||
// Mock AI Reply
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (!mounted) return;
|
||||
final newAiMsg = Message(
|
||||
id: DateTime.now().toString(),
|
||||
text: '我收到了你的信号: "${newUserMsg.text}"。这让我感觉很好...',
|
||||
sender: MessageSender.ai,
|
||||
type: MessageType.text,
|
||||
timestamp: DateTime.now()
|
||||
// 如果没有消息,添加问候语
|
||||
if (_messages.isEmpty) {
|
||||
final greeting = ChatService.getGreeting(character);
|
||||
final greetingMessage = ChatMessage.assistant(greeting);
|
||||
setState(() {
|
||||
_messages = [greetingMessage];
|
||||
});
|
||||
await ChatStorageService.addMessage(widget.characterId, greetingMessage);
|
||||
}
|
||||
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
if (_controller.text.trim().isEmpty || _character == null || _isLoading) return;
|
||||
|
||||
final userText = _controller.text.trim();
|
||||
_controller.clear();
|
||||
|
||||
// 添加用户消息
|
||||
final userMessage = ChatMessage.user(userText);
|
||||
setState(() {
|
||||
_messages = [..._messages, userMessage];
|
||||
_isLoading = true;
|
||||
_isTyping = true;
|
||||
_typingContent = '';
|
||||
});
|
||||
await ChatStorageService.addMessage(widget.characterId, userMessage);
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
// 调用 AI
|
||||
final response = await ChatService.sendMessage(
|
||||
character: _character!,
|
||||
messages: _messages,
|
||||
userMessage: userText,
|
||||
onStream: (content) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_typingContent = content;
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 添加 AI 回复
|
||||
final aiMessage = ChatMessage.assistant(response);
|
||||
setState(() {
|
||||
_messages = [..._messages, aiMessage];
|
||||
_isTyping = false;
|
||||
_typingContent = '';
|
||||
});
|
||||
await ChatStorageService.addMessage(widget.characterId, aiMessage);
|
||||
_scrollToBottom();
|
||||
} catch (e) {
|
||||
debugPrint('❌ 发送消息失败: $e');
|
||||
// 显示错误消息
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('发送失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_messages.add(newAiMsg);
|
||||
_isTyping = false;
|
||||
_typingContent = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_character == null) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFA855F7)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final avatarUrl = CharacterRepository.getAvatarUrl(_character!.avatarPath);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
flexibleSpace: ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(color: Colors.black.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage: NetworkImage(_character.avatar),
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_character.name, style: const TextStyle(fontSize: 16, color: Colors.white)),
|
||||
Row(
|
||||
children: [
|
||||
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFF10B981), shape: BoxShape.circle)),
|
||||
const SizedBox(width: 4),
|
||||
Text('Online', style: TextStyle(fontSize: 10, color: Colors.white.withOpacity(0.7))),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(LucideIcons.moreVertical, color: Colors.white), onPressed: () {}),
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2E1065),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
|
||||
)
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.only(top: 120, bottom: 20, left: 16, right: 16),
|
||||
itemCount: _messages.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final msg = _messages[index];
|
||||
final isMe = msg.sender == MessageSender.user;
|
||||
return Align(
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe ? const Color(0xFFA855F7) : Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(2),
|
||||
bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Text(
|
||||
msg.text,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
flexibleSpace: ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(color: Colors.black.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
|
||||
// Input Area
|
||||
ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 10,
|
||||
top: 10,
|
||||
left: 16,
|
||||
right: 16
|
||||
),
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isVoiceMode = true),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage: NetworkImage(avatarUrl),
|
||||
radius: 16,
|
||||
onBackgroundImageError: (_, __) {},
|
||||
child: _character!.avatarPath == null
|
||||
? const Icon(LucideIcons.user, size: 16, color: Colors.white54)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_character!.name,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.white),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1))
|
||||
),
|
||||
child: const Icon(LucideIcons.phone, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.05),
|
||||
hintText: 'Type a message...',
|
||||
hintStyle: TextStyle(color: Colors.white.withOpacity(0.3)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
color: _getStatusColor(_character!.status),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _sendMessage,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(colors: [Color(0xFFA855F7), Color(0xFFEC4899)])
|
||||
),
|
||||
child: const Icon(LucideIcons.send, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_getStatusText(_character!.status),
|
||||
style: TextStyle(fontSize: 10, color: Colors.white.withOpacity(0.7)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
), // Column
|
||||
), // Container
|
||||
), // Scaffold
|
||||
|
||||
if (_isVoiceMode)
|
||||
VoiceModeOverlay(
|
||||
character: _character,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.trash2, color: Colors.white54, size: 20),
|
||||
onPressed: _clearChat,
|
||||
tooltip: '清除聊天记录',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.moreVertical, color: Colors.white),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2E1065),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.only(top: 120, bottom: 20, left: 16, right: 16),
|
||||
itemCount: _messages.length + (_isTyping ? 1 : 0),
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
// 如果是正在输入的消息
|
||||
if (_isTyping && index == _messages.length) {
|
||||
return _buildTypingBubble();
|
||||
}
|
||||
|
||||
final msg = _messages[index];
|
||||
return _buildMessageBubble(msg, avatarUrl);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Input Area
|
||||
_buildInputArea(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (_isVoiceMode && _character != null)
|
||||
VoiceModeOverlay(
|
||||
character: _character!,
|
||||
onClose: () => setState(() => _isVoiceMode = false),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ChatMessage msg, String avatarUrl) {
|
||||
final isMe = msg.isUser;
|
||||
return Row(
|
||||
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMe) ...[
|
||||
CircleAvatar(
|
||||
backgroundImage: NetworkImage(avatarUrl),
|
||||
radius: 16,
|
||||
onBackgroundImageError: (_, __) {},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.7,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe ? const Color(0xFFA855F7) : Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(2),
|
||||
bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Text(
|
||||
msg.content,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isMe) const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypingBubble() {
|
||||
final avatarUrl = CharacterRepository.getAvatarUrl(_character!.avatarPath);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage: NetworkImage(avatarUrl),
|
||||
radius: 16,
|
||||
onBackgroundImageError: (_, __) {},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.7,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(2),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: _typingContent.isEmpty
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDot(0),
|
||||
_buildDot(1),
|
||||
_buildDot(2),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
_typingContent,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDot(int index) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.3, end: 1.0),
|
||||
duration: Duration(milliseconds: 600 + index * 200),
|
||||
curve: Curves.easeInOut,
|
||||
builder: (context, value, child) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(value),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea() {
|
||||
return ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 10,
|
||||
top: 10,
|
||||
left: 16,
|
||||
right: 16,
|
||||
),
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isVoiceMode = true),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: const Icon(LucideIcons.phone, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
enabled: !_isLoading,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.05),
|
||||
hintText: _isLoading ? 'AI 正在思考...' : '输入消息...',
|
||||
hintStyle: TextStyle(color: Colors.white.withOpacity(0.3)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _isLoading ? null : _sendMessage,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: _isLoading
|
||||
? null
|
||||
: const LinearGradient(colors: [Color(0xFFA855F7), Color(0xFFEC4899)]),
|
||||
color: _isLoading ? Colors.white.withOpacity(0.1) : null,
|
||||
),
|
||||
child: _isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white54,
|
||||
),
|
||||
)
|
||||
: const Icon(LucideIcons.send, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearChat() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF2E1065),
|
||||
title: const Text('清除聊天记录', style: TextStyle(color: Colors.white)),
|
||||
content: const Text('确定要清除所有聊天记录吗?此操作不可恢复。',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消', style: TextStyle(color: Colors.white54)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('清除', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ChatStorageService.clearSession(widget.characterId);
|
||||
final greeting = ChatService.getGreeting(_character!);
|
||||
final greetingMessage = ChatMessage.assistant(greeting);
|
||||
setState(() {
|
||||
_messages = [greetingMessage];
|
||||
});
|
||||
await ChatStorageService.addMessage(widget.characterId, greetingMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor(CharacterStatus status) {
|
||||
switch (status) {
|
||||
case CharacterStatus.online:
|
||||
return const Color(0xFF10B981);
|
||||
case CharacterStatus.busy:
|
||||
return const Color(0xFFF59E0B);
|
||||
case CharacterStatus.offline:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusText(CharacterStatus status) {
|
||||
switch (status) {
|
||||
case CharacterStatus.online:
|
||||
return '在线';
|
||||
case CharacterStatus.busy:
|
||||
return '忙碌';
|
||||
case CharacterStatus.offline:
|
||||
return '离线';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../models/character.dart';
|
||||
import '../../core/core.dart';
|
||||
|
||||
class VoiceModeOverlay extends StatefulWidget {
|
||||
final Character character;
|
||||
final CharacterModel character;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const VoiceModeOverlay({
|
||||
@@ -23,6 +22,8 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
|
||||
bool _isSpeakerOn = true;
|
||||
late AnimationController _controller;
|
||||
|
||||
String get _avatarUrl => CharacterRepository.getAvatarUrl(widget.character.avatarPath);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -49,7 +50,7 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
|
||||
// Background Image with Blur
|
||||
Positioned.fill(
|
||||
child: Image.network(
|
||||
widget.character.avatar,
|
||||
_avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(color: const Color(0xFF2E1065));
|
||||
@@ -152,7 +153,7 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
|
||||
)
|
||||
],
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(widget.character.avatar),
|
||||
image: NetworkImage(_avatarUrl),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user