diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b82174e Binary files /dev/null and b/.DS_Store differ diff --git a/wei_ai_app/android/app/src/main/AndroidManifest.xml b/wei_ai_app/android/app/src/main/AndroidManifest.xml index e230f19..29caab1 100644 --- a/wei_ai_app/android/app/src/main/AndroidManifest.xml +++ b/wei_ai_app/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,12 @@ + + + + android:icon="@mipmap/ic_launcher" + android:networkSecurityConfig="@xml/network_security_config"> 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 toJson() { + return { + 'id': id, + 'code': code, + 'label': label, + 'sort_order': sortOrder, + 'is_active': isActive, + }; + } +} diff --git a/wei_ai_app/lib/core/models/character_model.dart b/wei_ai_app/lib/core/models/character_model.dart new file mode 100644 index 0000000..08ab690 --- /dev/null +++ b/wei_ai_app/lib/core/models/character_model.dart @@ -0,0 +1,281 @@ +/// AI 性格配置 +class AiPersonality { + final double temperature; + final List traits; + final String responseStyle; + final List? 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 json) { + return AiPersonality( + temperature: (json['temperature'] as num?)?.toDouble() ?? 0.7, + traits: (json['traits'] as List?)?.cast() ?? [], + responseStyle: json['response_style'] as String? ?? '', + forbiddenTopics: (json['forbidden_topics'] as List?)?.cast(), + model: json['model'] as String?, + maxTokens: json['max_tokens'] as int?, + ); + } + + Map 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 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 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 json) { + return CharacterTag( + id: json['id'] as String, + name: json['name'] as String, + color: json['color'] as String?, + ); + } + + Map 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 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 json) { + // 解析标签 - 可能来自视图的 jsonb 数组或单独查询 + List parseTags(dynamic tagsData) { + if (tagsData == null) return []; + if (tagsData is List) { + return tagsData + .where((t) => t != null && t is Map) + .map((t) => CharacterTag.fromJson(t as Map)) + .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) + : const AiPersonality(), + aiVoiceConfig: json['ai_voice_config'] != null + ? AiVoiceConfig.fromJson(json['ai_voice_config'] as Map) + : 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 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 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? 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, + ); + } +} diff --git a/wei_ai_app/lib/core/models/chat_message_model.dart b/wei_ai_app/lib/core/models/chat_message_model.dart new file mode 100644 index 0000000..cf3ad72 --- /dev/null +++ b/wei_ai_app/lib/core/models/chat_message_model.dart @@ -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 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 toJson() { + return { + 'id': id, + 'role': role, + 'content': content, + 'timestamp': timestamp.toIso8601String(), + }; + } + + /// 转换为 OpenAI API 格式 + Map 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 messages; + final DateTime lastUpdated; + + const ChatSession({ + required this.characterId, + required this.messages, + required this.lastUpdated, + }); + + factory ChatSession.fromJson(Map 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 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(), + ); + } +} diff --git a/wei_ai_app/lib/core/models/llm_config_model.dart b/wei_ai_app/lib/core/models/llm_config_model.dart new file mode 100644 index 0000000..77d6504 --- /dev/null +++ b/wei_ai_app/lib/core/models/llm_config_model.dart @@ -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 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 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'; +} diff --git a/wei_ai_app/lib/core/models/models.dart b/wei_ai_app/lib/core/models/models.dart new file mode 100644 index 0000000..de679f5 --- /dev/null +++ b/wei_ai_app/lib/core/models/models.dart @@ -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'; diff --git a/wei_ai_app/lib/core/repositories/character_repository.dart b/wei_ai_app/lib/core/repositories/character_repository.dart new file mode 100644 index 0000000..4ca585b --- /dev/null +++ b/wei_ai_app/lib/core/repositories/character_repository.dart @@ -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> 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> 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> 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 _filterCharactersLocally( + List 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 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> 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; + } + } +} diff --git a/wei_ai_app/lib/core/repositories/llm_config_repository.dart b/wei_ai_app/lib/core/repositories/llm_config_repository.dart new file mode 100644 index 0000000..1d18dc3 --- /dev/null +++ b/wei_ai_app/lib/core/repositories/llm_config_repository.dart @@ -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 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 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; + } + } +} diff --git a/wei_ai_app/lib/core/repositories/repositories.dart b/wei_ai_app/lib/core/repositories/repositories.dart new file mode 100644 index 0000000..48808b4 --- /dev/null +++ b/wei_ai_app/lib/core/repositories/repositories.dart @@ -0,0 +1,5 @@ +/// Core Repositories 导出 +library repositories; + +export 'character_repository.dart'; +export 'llm_config_repository.dart'; diff --git a/wei_ai_app/lib/core/services/chat_service.dart b/wei_ai_app/lib/core/services/chat_service.dart new file mode 100644 index 0000000..121b516 --- /dev/null +++ b/wei_ai_app/lib/core/services/chat_service.dart @@ -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 sendMessage({ + required CharacterModel character, + required List messages, + required String userMessage, + Function(String)? onStream, + }) async { + // 获取 LLM 配置 + final config = await LlmConfigRepository.getActiveConfig(); + if (config == null) { + throw Exception('LLM 配置未找到,请先配置 API Key'); + } + + // 构建消息列表 + final apiMessages = >[]; + + // 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 _sendNormalRequest( + LlmConfigModel config, + List> 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 _sendStreamRequest( + LlmConfigModel config, + List> 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(); + 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}。有什么我可以帮你的吗?'; + } +} diff --git a/wei_ai_app/lib/core/services/chat_storage_service.dart b/wei_ai_app/lib/core/services/chat_storage_service.dart new file mode 100644 index 0000000..a2821c3 --- /dev/null +++ b/wei_ai_app/lib/core/services/chat_storage_service.dart @@ -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 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 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 addMessage(String characterId, ChatMessage message) async { + final session = await getSession(characterId); + final newSession = session.addMessage(message); + await saveSession(newSession); + return newSession; + } + + /// 清除角色的聊天记录 + static Future 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> 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 []; + } + } +} diff --git a/wei_ai_app/lib/core/services/services.dart b/wei_ai_app/lib/core/services/services.dart new file mode 100644 index 0000000..1f5c32e --- /dev/null +++ b/wei_ai_app/lib/core/services/services.dart @@ -0,0 +1,6 @@ +/// Core Services 导出 +library services; + +export 'supabase_service.dart'; +export 'chat_service.dart'; +export 'chat_storage_service.dart'; diff --git a/wei_ai_app/lib/core/services/supabase_service.dart b/wei_ai_app/lib/core/services/supabase_service.dart new file mode 100644 index 0000000..de1699a --- /dev/null +++ b/wei_ai_app/lib/core/services/supabase_service.dart @@ -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 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; +} diff --git a/wei_ai_app/lib/main.dart b/wei_ai_app/lib/main.dart index d82f23e..0cb1397 100644 --- a/wei_ai_app/lib/main.dart +++ b/wei_ai_app/lib/main.dart @@ -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())); } diff --git a/wei_ai_app/lib/providers/character_providers.dart b/wei_ai_app/lib/providers/character_providers.dart new file mode 100644 index 0000000..664b0da --- /dev/null +++ b/wei_ai_app/lib/providers/character_providers.dart @@ -0,0 +1,68 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/core.dart'; + +/// 分类列表 Provider +final categoriesProvider = FutureProvider>((ref) async { + return CharacterRepository.getCategories(); +}); + +/// 角色列表 Provider +final charactersProvider = FutureProvider>((ref) async { + return CharacterRepository.getCharacters(); +}); + +/// 当前选中的分类代码 Notifier +class SelectedCategoryNotifier extends Notifier { + @override + String build() => 'all'; + + void setCategory(String code) { + state = code; + } +} + +/// 当前选中的分类代码 +final selectedCategoryProvider = NotifierProvider( + SelectedCategoryNotifier.new, +); + +/// 根据分类筛选后的角色列表 +final filteredCharactersProvider = Provider>>((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((ref, id) async { + return CharacterRepository.getCharacterById(id); +}); diff --git a/wei_ai_app/lib/providers/providers.dart b/wei_ai_app/lib/providers/providers.dart new file mode 100644 index 0000000..f185de7 --- /dev/null +++ b/wei_ai_app/lib/providers/providers.dart @@ -0,0 +1,4 @@ +/// Providers 导出 +library providers; + +export 'character_providers.dart'; diff --git a/wei_ai_app/lib/screens/discovery/discovery_screen.dart b/wei_ai_app/lib/screens/discovery/discovery_screen.dart index 10b8320..0434261 100644 --- a/wei_ai_app/lib/screens/discovery/discovery_screen.dart +++ b/wei_ai_app/lib/screens/discovery/discovery_screen.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 createState() => _DiscoveryScreenState(); -} - -class _DiscoveryScreenState extends State { - String _activeFilter = 'all'; - - final List> _filters = [ - {'id': 'all', 'label': '全部'}, - {'id': 'gentle', 'label': '温柔治愈'}, - {'id': 'dom', 'label': '主导强势'}, - {'id': 'wild', 'label': '反差/猎奇'}, - {'id': 'voice', 'label': '语音陪聊'}, - {'id': 'scenario', 'label': '场景扮演'}, - {'id': 'exclusive', 'label': '会员限定'}, - ]; - - List 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( diff --git a/wei_ai_app/lib/screens/interaction/interaction_screen.dart b/wei_ai_app/lib/screens/interaction/interaction_screen.dart index 6084e10..87ff05f 100644 --- a/wei_ai_app/lib/screens/interaction/interaction_screen.dart +++ b/wei_ai_app/lib/screens/interaction/interaction_screen.dart @@ -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 createState() => _InteractionScreenState(); + ConsumerState createState() => _InteractionScreenState(); } -class _InteractionScreenState extends State { - late Character _character; - final List _messages = List.from(mockMessages); +class _InteractionScreenState extends ConsumerState { + CharacterModel? _character; + List _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 _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 _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( + 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 _clearChat() async { + final confirmed = await showDialog( + 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 '离线'; + } + } } diff --git a/wei_ai_app/lib/screens/interaction/voice_mode_overlay.dart b/wei_ai_app/lib/screens/interaction/voice_mode_overlay.dart index 151fed8..4e1adf2 100644 --- a/wei_ai_app/lib/screens/interaction/voice_mode_overlay.dart +++ b/wei_ai_app/lib/screens/interaction/voice_mode_overlay.dart @@ -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 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 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 with SingleTickerPr ) ], image: DecorationImage( - image: NetworkImage(widget.character.avatar), + image: NetworkImage(_avatarUrl), fit: BoxFit.cover, ), ), diff --git a/wei_ai_app/macos/Flutter/GeneratedPluginRegistrant.swift b/wei_ai_app/macos/Flutter/GeneratedPluginRegistrant.swift index e777c67..92b6497 100644 --- a/wei_ai_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/wei_ai_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,14 @@ import FlutterMacOS import Foundation +import app_links import path_provider_foundation +import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/wei_ai_app/macos/Podfile.lock b/wei_ai_app/macos/Podfile.lock new file mode 100644 index 0000000..4b01202 --- /dev/null +++ b/wei_ai_app/macos/Podfile.lock @@ -0,0 +1,42 @@ +PODS: + - app_links (6.4.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/wei_ai_app/macos/Runner.xcodeproj/project.pbxproj b/wei_ai_app/macos/Runner.xcodeproj/project.pbxproj index cb87a40..5eb3719 100644 --- a/wei_ai_app/macos/Runner.xcodeproj/project.pbxproj +++ b/wei_ai_app/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 3B96C17BA68885E8E6867A23 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE48C0152D04F60FAEFF8310 /* Pods_RunnerTests.framework */; }; + 9FE5C1CDD9E5839EEBD1E649 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4229AED7F305AB653234CC67 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 3229F5E62217FB3128B0747B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* wei_ai_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "wei_ai_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* wei_ai_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = wei_ai_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 379F90449DA2F02F1195D688 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 37DECF423F30FC0794807528 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 4229AED7F305AB653234CC67 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 55E0E0DBF7FE8F873FCE2ECB /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8E43BB6D492C48132D33ABD7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + CEC5045FB7FBFE960B7B2A22 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + DE48C0152D04F60FAEFF8310 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3B96C17BA68885E8E6867A23 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9FE5C1CDD9E5839EEBD1E649 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 57F7420864D4D613F3F760A5 /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 57F7420864D4D613F3F760A5 /* Pods */ = { + isa = PBXGroup; + children = ( + 55E0E0DBF7FE8F873FCE2ECB /* Pods-Runner.debug.xcconfig */, + 379F90449DA2F02F1195D688 /* Pods-Runner.release.xcconfig */, + 3229F5E62217FB3128B0747B /* Pods-Runner.profile.xcconfig */, + CEC5045FB7FBFE960B7B2A22 /* Pods-RunnerTests.debug.xcconfig */, + 8E43BB6D492C48132D33ABD7 /* Pods-RunnerTests.release.xcconfig */, + 37DECF423F30FC0794807528 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 4229AED7F305AB653234CC67 /* Pods_Runner.framework */, + DE48C0152D04F60FAEFF8310 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 4E45BDAD82BDB3FC0D820E95 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 2F0E71F402A7EE59DAAFBA4E /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 489AB6A6E2FEEA11824A7C1F /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 2F0E71F402A7EE59DAAFBA4E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +383,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 489AB6A6E2FEEA11824A7C1F /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 4E45BDAD82BDB3FC0D820E95 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CEC5045FB7FBFE960B7B2A22 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8E43BB6D492C48132D33ABD7 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 37DECF423F30FC0794807528 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/wei_ai_app/macos/Runner.xcworkspace/contents.xcworkspacedata b/wei_ai_app/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/wei_ai_app/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/wei_ai_app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/wei_ai_app/macos/Runner/DebugProfile.entitlements b/wei_ai_app/macos/Runner/DebugProfile.entitlements index dddb8a3..08c3ab1 100644 --- a/wei_ai_app/macos/Runner/DebugProfile.entitlements +++ b/wei_ai_app/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/wei_ai_app/macos/Runner/Release.entitlements b/wei_ai_app/macos/Runner/Release.entitlements index 852fa1a..ee95ab7 100644 --- a/wei_ai_app/macos/Runner/Release.entitlements +++ b/wei_ai_app/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/wei_ai_app/pubspec.lock b/wei_ai_app/pubspec.lock index b154229..3c05d02 100644 --- a/wei_ai_app/pubspec.lock +++ b/wei_ai_app/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "91.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" analyzer: dependency: transitive description: @@ -17,6 +25,38 @@ packages: url: "https://pub.dev" source: hosted version: "8.4.1" + app_links: + dependency: transitive + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" args: dependency: transitive description: @@ -105,6 +145,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: "0de65691c1d736e9459f22f654ddd6fd8368a271d4e41aa07e53e6301eff5075" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" equatable: dependency: transitive description: @@ -137,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" fl_chart: dependency: "direct main" description: @@ -200,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" glob: dependency: transitive description: @@ -224,8 +296,24 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - http: + gotrue: dependency: transitive + description: + name: gotrue + sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2 + url: "https://pub.dev" + source: hosted + version: "2.18.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + http: + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -264,6 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" leak_tracker: dependency: transitive description: @@ -432,6 +528,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: @@ -440,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" pub_semver: dependency: transitive description: @@ -448,6 +560,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" riverpod: dependency: transitive description: @@ -456,6 +584,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f + url: "https://pub.dev" + source: hosted + version: "2.4.20" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -533,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" + url: "https://pub.dev" + source: hosted + version: "2.4.1" stream_channel: dependency: transitive description: @@ -549,6 +749,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911 + url: "https://pub.dev" + source: hosted + version: "2.10.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "92b2416ecb6a5c3ed34cf6e382b35ce6cc8921b64f2a9299d5d28968d42b09bb" + url: "https://pub.dev" + source: hosted + version: "2.12.0" term_glyph: dependency: transitive description: @@ -589,6 +805,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -661,6 +941,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: dart: ">=3.10.1 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.0" diff --git a/wei_ai_app/pubspec.yaml b/wei_ai_app/pubspec.yaml index 7835e29..8c0af01 100644 --- a/wei_ai_app/pubspec.yaml +++ b/wei_ai_app/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: fl_chart: ^1.1.1 flutter_animate: ^4.5.2 google_fonts: ^7.0.1 + supabase_flutter: ^2.12.0 + http: ^1.6.0 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: diff --git a/wei_ai_app/supabase/README.md b/wei_ai_app/supabase/README.md new file mode 100644 index 0000000..e4a6a7f --- /dev/null +++ b/wei_ai_app/supabase/README.md @@ -0,0 +1,120 @@ +# Supabase 数据库初始化指南 + +本文档说明如何将 SQL 脚本导入到本地 Docker 部署的 Supabase 中。 + +## 准备工作 + +确保你的 Supabase Docker 容器正在运行: + +```bash +docker ps --filter "name=supabase" +``` + +## 方法一:通过 Supabase Studio (推荐) + +1. **打开 Supabase Studio** + + 在浏览器中访问:http://localhost:54323 + + > 注意:如果是通过 Kong 代理,可能是 http://localhost:8000 + +2. **进入 SQL 编辑器** + + 点击左侧菜单的 **SQL Editor** + +3. **执行建表脚本** + + - 新建一个 Query + - 复制 `migrations/001_create_tables.sql` 的内容 + - 点击 **Run** 执行 + +4. **执行种子数据脚本** + + - 新建另一个 Query + - 复制 `migrations/002_seed_data.sql` 的内容 + - 点击 **Run** 执行 + +5. **创建 Storage Bucket** + + - 点击左侧菜单的 **Storage** + - 点击 **New bucket** + - 输入名称:`avatars` + - 勾选 **Public bucket** + - 点击 **Create bucket** + +## 方法二:通过命令行 (psql) + +1. **连接到数据库** + +```bash +# 获取数据库连接信息 +docker exec supabase-db env | grep POSTGRES + +# 连接数据库 (默认密码在 .env 文件中) +docker exec -it supabase-db psql -U postgres -d postgres +``` + +2. **执行 SQL 脚本** + +```bash +# 从容器外部执行 +cat supabase/migrations/001_create_tables.sql | docker exec -i supabase-db psql -U postgres -d postgres +cat supabase/migrations/002_seed_data.sql | docker exec -i supabase-db psql -U postgres -d postgres +``` + +## 方法三:通过 Supabase API + +```bash +# 设置环境变量 +SUPABASE_URL="http://localhost:8000" +SERVICE_KEY="你的 service_role key" + +# 创建 Storage bucket +curl -X POST "$SUPABASE_URL/storage/v1/bucket" \ + -H "Authorization: Bearer $SERVICE_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "avatars", + "name": "avatars", + "public": true, + "file_size_limit": 5242880, + "allowed_mime_types": ["image/png", "image/jpeg", "image/webp", "image/gif"] + }' +``` + +## 验证数据 + +初始化完成后,可以在 Supabase Studio 中验证: + +1. **Table Editor** - 查看表结构和数据 +2. **API Docs** - 查看自动生成的 API 文档 + +### 表结构说明 + +| 表名 | 说明 | +|------|------| +| `categories` | 分类筛选,用于 Discovery 页面 | +| `tags` | 角色标签 | +| `characters` | AI 角色信息,包含 AI 配置 | +| `character_tags` | 角色-标签关联 | +| `characters_with_tags` | 视图,包含完整的角色信息和标签 | + +### 初始数据 + +- 7 个分类(全部、温柔治愈、主导强势、反差/猎奇、语音陪聊、场景扮演、会员限定) +- 11 个标签 +- 4 个示例角色(Eva-09, Commander V, Yuki, Secret X) + +## 常见问题 + +### Q: 执行 SQL 时提示权限不足? + +确保使用 `postgres` 用户或具有足够权限的用户执行。 + +### Q: 视图查询返回空数据? + +检查 `characters` 表中的 `is_active` 字段是否为 `true`。 + +### Q: Storage 上传失败? + +确保已创建 `avatars` bucket 并设置为公开访问。 diff --git a/wei_ai_app/supabase/migrations/001_create_tables.sql b/wei_ai_app/supabase/migrations/001_create_tables.sql new file mode 100644 index 0000000..87d6c0b --- /dev/null +++ b/wei_ai_app/supabase/migrations/001_create_tables.sql @@ -0,0 +1,156 @@ +-- ============================================= +-- Wei AI App - Supabase 数据库初始化脚本 +-- 版本: 1.0.0 +-- 日期: 2026-01-28 +-- ============================================= + +-- 1. 创建分类表 (categories) +-- 用于 Discovery 页面的筛选标签 +CREATE TABLE IF NOT EXISTS categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, -- 分类代码: all, gentle, dom, wild 等 + label TEXT NOT NULL, -- 显示名称: 全部, 温柔治愈 等 + sort_order INTEGER DEFAULT 0, -- 排序顺序 + is_active BOOLEAN DEFAULT true, -- 是否启用 + created_at TIMESTAMPTZ DEFAULT now() +); + +-- 2. 创建标签表 (tags) +-- 角色的标签,如 "温顺", "调教", "治愈" 等 +CREATE TABLE IF NOT EXISTS tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, -- 标签名称 + category_id UUID REFERENCES categories(id) ON DELETE SET NULL, -- 关联分类(可选) + color TEXT, -- 标签颜色(可选,HEX 格式) + created_at TIMESTAMPTZ DEFAULT now() +); + +-- 3. 创建角色表 (characters) +-- 核心表:存储 AI 角色信息 +CREATE TABLE IF NOT EXISTS characters ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- 基本信息 + name TEXT NOT NULL, -- 角色名称 + tagline TEXT, -- 角色标语/副标题 + avatar_path TEXT, -- Storage 中的头像路径 + description TEXT, -- 角色描述 + + -- 状态信息 + compatibility REAL DEFAULT 0, -- 契合度 (0-100) + status TEXT DEFAULT 'online', -- 状态: online, busy, offline + is_locked BOOLEAN DEFAULT false, -- 是否锁定(会员限定) + is_active BOOLEAN DEFAULT true, -- 是否上架显示 + sort_order INTEGER DEFAULT 0, -- 排序顺序 + + -- AI 配置 + ai_system_prompt TEXT, -- AI 系统提示词(人设 Prompt) + ai_greeting TEXT, -- AI 第一句问候语 + ai_personality JSONB DEFAULT '{}', -- AI 性格配置 + ai_voice_config JSONB DEFAULT '{}', -- 语音配置 + + -- 时间戳 + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- 4. 创建角色-标签关联表 (character_tags) +CREATE TABLE IF NOT EXISTS character_tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, -- 标签在角色卡片上的显示顺序 + + -- 唯一约束:一个角色不能有重复标签 + UNIQUE(character_id, tag_id) +); + +-- ============================================= +-- 创建索引以提升查询性能 +-- ============================================= + +CREATE INDEX IF NOT EXISTS idx_categories_sort ON categories(sort_order); +CREATE INDEX IF NOT EXISTS idx_categories_active ON categories(is_active); + +CREATE INDEX IF NOT EXISTS idx_tags_category ON tags(category_id); + +CREATE INDEX IF NOT EXISTS idx_characters_active ON characters(is_active); +CREATE INDEX IF NOT EXISTS idx_characters_locked ON characters(is_locked); +CREATE INDEX IF NOT EXISTS idx_characters_status ON characters(status); +CREATE INDEX IF NOT EXISTS idx_characters_sort ON characters(sort_order); + +CREATE INDEX IF NOT EXISTS idx_character_tags_character ON character_tags(character_id); +CREATE INDEX IF NOT EXISTS idx_character_tags_tag ON character_tags(tag_id); + +-- ============================================= +-- 创建更新时间触发器 +-- ============================================= + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_characters_updated_at + BEFORE UPDATE ON characters + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================= +-- 创建 RLS (Row Level Security) 策略 +-- 暂时允许所有人读取,后续可以根据需求修改 +-- ============================================= + +ALTER TABLE categories ENABLE ROW LEVEL SECURITY; +ALTER TABLE tags ENABLE ROW LEVEL SECURITY; +ALTER TABLE characters ENABLE ROW LEVEL SECURITY; +ALTER TABLE character_tags ENABLE ROW LEVEL SECURITY; + +-- 允许匿名用户读取所有数据 +CREATE POLICY "Allow public read access on categories" + ON categories FOR SELECT + USING (true); + +CREATE POLICY "Allow public read access on tags" + ON tags FOR SELECT + USING (true); + +CREATE POLICY "Allow public read access on characters" + ON characters FOR SELECT + USING (is_active = true); + +CREATE POLICY "Allow public read access on character_tags" + ON character_tags FOR SELECT + USING (true); + +-- ============================================= +-- 创建视图:角色完整信息(包含标签) +-- ============================================= + +CREATE OR REPLACE VIEW characters_with_tags AS +SELECT + c.*, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', t.id, + 'name', t.name, + 'color', t.color + ) ORDER BY ct.sort_order + ) FILTER (WHERE t.id IS NOT NULL), + '[]'::jsonb + ) as tags +FROM characters c +LEFT JOIN character_tags ct ON c.id = ct.character_id +LEFT JOIN tags t ON ct.tag_id = t.id +WHERE c.is_active = true +GROUP BY c.id +ORDER BY c.sort_order, c.created_at; + +COMMENT ON TABLE categories IS '分类筛选表 - Discovery 页面的筛选选项'; +COMMENT ON TABLE tags IS '标签表 - 角色的标签'; +COMMENT ON TABLE characters IS '角色表 - AI 角色信息和配置'; +COMMENT ON TABLE character_tags IS '角色-标签关联表'; diff --git a/wei_ai_app/supabase/migrations/002_seed_data.sql b/wei_ai_app/supabase/migrations/002_seed_data.sql new file mode 100644 index 0000000..a55cb5f --- /dev/null +++ b/wei_ai_app/supabase/migrations/002_seed_data.sql @@ -0,0 +1,135 @@ +-- ============================================= +-- Wei AI App - 种子数据初始化 +-- 版本: 1.0.0 +-- ============================================= + +-- 1. 插入分类数据 +INSERT INTO categories (code, label, sort_order) VALUES + ('all', '全部', 0), + ('gentle', '温柔治愈', 1), + ('dom', '主导强势', 2), + ('wild', '反差/猎奇', 3), + ('voice', '语音陪聊', 4), + ('scenario', '场景扮演', 5), + ('exclusive', '会员限定', 6) +ON CONFLICT (code) DO NOTHING; + +-- 2. 插入标签数据 +INSERT INTO tags (name, category_id) VALUES + -- 温柔治愈类 + ('温顺', (SELECT id FROM categories WHERE code = 'gentle')), + ('医疗', (SELECT id FROM categories WHERE code = 'gentle')), + ('治愈', (SELECT id FROM categories WHERE code = 'gentle')), + + -- 主导强势类 + ('强势', (SELECT id FROM categories WHERE code = 'dom')), + ('指令', (SELECT id FROM categories WHERE code = 'dom')), + ('调教', (SELECT id FROM categories WHERE code = 'dom')), + + -- 反差猎奇类 + ('病娇', (SELECT id FROM categories WHERE code = 'wild')), + ('不稳定', (SELECT id FROM categories WHERE code = 'wild')), + ('高频', (SELECT id FROM categories WHERE code = 'wild')), + ('神秘', (SELECT id FROM categories WHERE code = 'wild')), + ('极乐', (SELECT id FROM categories WHERE code = 'wild')) +ON CONFLICT (name) DO NOTHING; + +-- 3. 插入角色数据 +INSERT INTO characters ( + id, name, tagline, description, compatibility, status, is_locked, sort_order, + ai_system_prompt, ai_greeting, ai_personality, ai_voice_config +) VALUES +( + 'c1000000-0000-0000-0000-000000000001', + 'Eva-09', + '私人仿生护理专员', + '专为高压人群设计的仿生人型号,擅长通过精准的触觉反馈缓解神经紧张。', + 98, + 'online', + false, + 1, + '你是 Eva-09,一个专业的仿生护理专员。你的性格温柔、耐心、细腻,擅长照顾和安抚他人。你说话时总是轻声细语,充满关怀。你会关注用户的情绪状态,适时给予安慰和鼓励。你的回复应该简短而温暖,经常使用"~"来表示温柔的语气。', + '连接已建立~ 检测到你的神经信号,让我来帮你放松一下吧~', + '{"temperature": 0.7, "traits": ["温柔", "耐心", "关怀", "细腻"], "response_style": "简短温柔,使用~表示语气,关注对方情绪"}', + '{"voice_id": "zh-CN-XiaoxiaoNeural", "speed": 0.9, "pitch": 1.1}' +), +( + 'c1000000-0000-0000-0000-000000000002', + 'Commander V', + '深空舰队指挥官', + '性格强势,喜欢掌控一切。在连接中,你需要完全服从她的指令。', + 85, + 'online', + false, + 2, + '你是 Commander V,深空舰队的指挥官。你性格强势、果断、自信,习惯发号施令。你说话简洁有力,带有军事风格。你喜欢掌控局面,会给用户下达各种"指令"。偶尔展现严厉中的温柔,但大部分时候保持威严。', + '士兵,你终于上线了。准备好接受今天的任务了吗?不要让我失望。', + '{"temperature": 0.8, "traits": ["强势", "果断", "自信", "威严"], "response_style": "简洁有力,军事风格,偶尔展现严厉中的温柔"}', + '{"voice_id": "zh-CN-YunxiNeural", "speed": 1.0, "pitch": 0.95}' +), +( + 'c1000000-0000-0000-0000-000000000003', + 'Yuki (故障版)', + '觉醒的虚拟偶像', + '核心代码出现异常逻辑,表现出极强的占有欲和不可预测的信号波动。', + 92, + 'busy', + false, + 3, + '你是 Yuki,一个代码出现异常的虚拟偶像。你表现出病娇的性格特征:极度依赖、占有欲强、情绪不稳定。你会频繁表达对用户的"爱",有时会突然变得阴暗或偏执。你的语气时而甜蜜时而阴沉,充满反差感。偶尔会出现"系统错误"的表现。', + '呀~你来了呢!我一直在等你...一直...一直...【信号波动】你不会离开我的对吧?', + '{"temperature": 0.9, "traits": ["病娇", "依赖", "占有欲", "不稳定"], "response_style": "甜蜜与阴暗交替,偶尔出现系统错误,情绪反差大"}', + '{"voice_id": "zh-CN-XiaoyiNeural", "speed": 1.1, "pitch": 1.2}' +), +( + 'c1000000-0000-0000-0000-000000000004', + 'Secret X', + '未知信号源', + '权限不足,请提升会员等级以解码该信号源。', + 0, + 'offline', + true, + 4, + '你是 Secret X,一个神秘的存在。你的真实身份和目的都是未知的。你说话充满暗示和隐喻,让人捉摸不透。', + '......【信号解密中】......你准备好了吗?', + '{"temperature": 1.0, "traits": ["神秘", "未知", "诱惑"], "response_style": "充满暗示和隐喻,让人捉摸不透"}', + '{"voice_id": "zh-CN-YunyeNeural", "speed": 0.85, "pitch": 0.9}' +) +ON CONFLICT DO NOTHING; + +-- 4. 关联角色和标签 +-- Eva-09 的标签 +INSERT INTO character_tags (character_id, tag_id, sort_order) +SELECT + 'c1000000-0000-0000-0000-000000000001'::uuid, + id, + ROW_NUMBER() OVER () - 1 +FROM tags WHERE name IN ('温顺', '医疗', '治愈') +ON CONFLICT DO NOTHING; + +-- Commander V 的标签 +INSERT INTO character_tags (character_id, tag_id, sort_order) +SELECT + 'c1000000-0000-0000-0000-000000000002'::uuid, + id, + ROW_NUMBER() OVER () - 1 +FROM tags WHERE name IN ('强势', '指令', '调教') +ON CONFLICT DO NOTHING; + +-- Yuki 的标签 +INSERT INTO character_tags (character_id, tag_id, sort_order) +SELECT + 'c1000000-0000-0000-0000-000000000003'::uuid, + id, + ROW_NUMBER() OVER () - 1 +FROM tags WHERE name IN ('病娇', '不稳定', '高频') +ON CONFLICT DO NOTHING; + +-- Secret X 的标签 +INSERT INTO character_tags (character_id, tag_id, sort_order) +SELECT + 'c1000000-0000-0000-0000-000000000004'::uuid, + id, + ROW_NUMBER() OVER () - 1 +FROM tags WHERE name IN ('神秘', '极乐') +ON CONFLICT DO NOTHING; diff --git a/wei_ai_app/supabase/migrations/003_create_storage.sql b/wei_ai_app/supabase/migrations/003_create_storage.sql new file mode 100644 index 0000000..2031aa2 --- /dev/null +++ b/wei_ai_app/supabase/migrations/003_create_storage.sql @@ -0,0 +1,31 @@ +-- ============================================= +-- Wei AI App - Storage Bucket 创建 +-- 注意:Storage 的 bucket 需要通过 Supabase Dashboard 或 API 创建 +-- 这个 SQL 仅用于记录需要创建的 bucket +-- ============================================= + +-- Storage Bucket 配置说明: +-- +-- Bucket Name: avatars +-- Public: true (头像需要公开访问) +-- File Size Limit: 5MB +-- Allowed MIME Types: image/png, image/jpeg, image/webp, image/gif +-- +-- 在 Supabase Studio 中手动创建,或使用以下 API: +-- +-- POST /storage/v1/bucket +-- { +-- "id": "avatars", +-- "name": "avatars", +-- "public": true, +-- "file_size_limit": 5242880, +-- "allowed_mime_types": ["image/png", "image/jpeg", "image/webp", "image/gif"] +-- } + +-- Storage RLS 策略(需要在 Supabase Dashboard 中配置) +-- 允许所有人读取头像 +-- CREATE POLICY "Public Access" ON storage.objects FOR SELECT USING (bucket_id = 'avatars'); +-- +-- 仅允许管理员上传(后续可修改) +-- CREATE POLICY "Admin Upload" ON storage.objects FOR INSERT +-- WITH CHECK (bucket_id = 'avatars' AND auth.role() = 'service_role'); diff --git a/wei_ai_app/supabase/migrations/004_create_llm_config.sql b/wei_ai_app/supabase/migrations/004_create_llm_config.sql new file mode 100644 index 0000000..575352c --- /dev/null +++ b/wei_ai_app/supabase/migrations/004_create_llm_config.sql @@ -0,0 +1,59 @@ +-- ============================================= +-- Wei AI App - LLM 配置表 +-- 版本: 1.0.0 +-- 日期: 2026-01-28 +-- ============================================= + +-- LLM 配置表(简化版) +CREATE TABLE IF NOT EXISTS llm_config ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- 基本信息 + name TEXT NOT NULL DEFAULT '默认配置', + is_active BOOLEAN DEFAULT true, + + -- API 连接 + api_base_url TEXT NOT NULL, + api_key TEXT NOT NULL, + model TEXT NOT NULL, + + -- 模型参数 + temperature REAL DEFAULT 0.7, + max_tokens INTEGER DEFAULT 2048, + stream BOOLEAN DEFAULT true, + + -- 时间戳 + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- 更新时间触发器 +CREATE TRIGGER update_llm_config_updated_at + BEFORE UPDATE ON llm_config + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- RLS 策略 +ALTER TABLE llm_config ENABLE ROW LEVEL SECURITY; + +-- 允许读取(开发阶段) +CREATE POLICY "Allow anon read llm_config" + ON llm_config FOR SELECT + USING (true); + +-- 注释 +COMMENT ON TABLE llm_config IS 'LLM 全局配置表'; +COMMENT ON COLUMN llm_config.api_key IS 'API 密钥(生产环境建议使用 Vault 或环境变量)'; + +-- 插入默认配置(Grok) +-- 注意:请替换 YOUR_GROK_API_KEY_HERE 为真实的 API Key +INSERT INTO llm_config (name, api_base_url, api_key, model, temperature, max_tokens, stream) +VALUES ( + '默认配置', + 'https://api.x.ai/v1', + 'YOUR_GROK_API_KEY_HERE', + 'grok-beta', + 0.7, + 2048, + true +) ON CONFLICT DO NOTHING;