feat: 角色卡 demo
This commit is contained in:
183
wei_ai_app/lib/core/services/chat_service.dart
Normal file
183
wei_ai_app/lib/core/services/chat_service.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/chat_message_model.dart';
|
||||
import '../models/llm_config_model.dart';
|
||||
import '../models/character_model.dart';
|
||||
import '../repositories/llm_config_repository.dart';
|
||||
|
||||
/// AI 聊天服务
|
||||
///
|
||||
/// 负责调用 LLM API 进行对话
|
||||
class ChatService {
|
||||
ChatService._();
|
||||
|
||||
/// 发送消息并获取 AI 回复
|
||||
///
|
||||
/// [character] 当前对话的角色
|
||||
/// [messages] 历史消息列表
|
||||
/// [userMessage] 用户新发送的消息
|
||||
/// [onStream] 流式输出回调(每次收到新内容时调用)
|
||||
static Future<String> sendMessage({
|
||||
required CharacterModel character,
|
||||
required List<ChatMessage> messages,
|
||||
required String userMessage,
|
||||
Function(String)? onStream,
|
||||
}) async {
|
||||
// 获取 LLM 配置
|
||||
final config = await LlmConfigRepository.getActiveConfig();
|
||||
if (config == null) {
|
||||
throw Exception('LLM 配置未找到,请先配置 API Key');
|
||||
}
|
||||
|
||||
// 构建消息列表
|
||||
final apiMessages = <Map<String, String>>[];
|
||||
|
||||
// 1. System prompt(角色人设)
|
||||
final systemPrompt = character.aiSystemPrompt ??
|
||||
'你是 ${character.name},${character.tagline ?? "一个AI角色"}。${character.description ?? ""}';
|
||||
apiMessages.add({'role': 'system', 'content': systemPrompt});
|
||||
|
||||
// 2. 历史消息(最多保留最近 20 条)
|
||||
final recentMessages = messages.length > 20
|
||||
? messages.sublist(messages.length - 20)
|
||||
: messages;
|
||||
for (final msg in recentMessages) {
|
||||
if (msg.role != 'system') {
|
||||
apiMessages.add(msg.toApiFormat());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 新用户消息
|
||||
apiMessages.add({'role': 'user', 'content': userMessage});
|
||||
|
||||
debugPrint('📤 发送到 LLM: ${apiMessages.length} 条消息');
|
||||
|
||||
// 判断是否使用流式输出
|
||||
if (config.stream && onStream != null) {
|
||||
return _sendStreamRequest(config, apiMessages, onStream);
|
||||
} else {
|
||||
return _sendNormalRequest(config, apiMessages);
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通请求(非流式)
|
||||
static Future<String> _sendNormalRequest(
|
||||
LlmConfigModel config,
|
||||
List<Map<String, String>> messages,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse(config.chatCompletionsUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${config.apiKey}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'model': config.model,
|
||||
'messages': messages,
|
||||
'temperature': config.temperature,
|
||||
'max_tokens': config.maxTokens,
|
||||
'stream': false,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('❌ LLM API 错误: ${response.statusCode} ${response.body}');
|
||||
throw Exception('API 请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
final content = data['choices'][0]['message']['content'] as String;
|
||||
debugPrint('📥 收到回复: ${content.substring(0, content.length.clamp(0, 50))}...');
|
||||
return content;
|
||||
} catch (e) {
|
||||
debugPrint('❌ LLM 请求失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式请求
|
||||
static Future<String> _sendStreamRequest(
|
||||
LlmConfigModel config,
|
||||
List<Map<String, String>> messages,
|
||||
Function(String) onStream,
|
||||
) async {
|
||||
try {
|
||||
final client = http.Client();
|
||||
final request = http.Request('POST', Uri.parse(config.chatCompletionsUrl));
|
||||
|
||||
request.headers.addAll({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${config.apiKey}',
|
||||
'Accept': 'text/event-stream',
|
||||
});
|
||||
|
||||
request.body = jsonEncode({
|
||||
'model': config.model,
|
||||
'messages': messages,
|
||||
'temperature': config.temperature,
|
||||
'max_tokens': config.maxTokens,
|
||||
'stream': true,
|
||||
});
|
||||
|
||||
final response = await client.send(request);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final body = await response.stream.bytesToString();
|
||||
debugPrint('❌ LLM API 错误: ${response.statusCode} $body');
|
||||
throw Exception('API 请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final completer = Completer<String>();
|
||||
final buffer = StringBuffer();
|
||||
|
||||
response.stream
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
(line) {
|
||||
if (line.startsWith('data: ')) {
|
||||
final data = line.substring(6);
|
||||
if (data == '[DONE]') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final json = jsonDecode(data);
|
||||
final delta = json['choices']?[0]?['delta']?['content'];
|
||||
if (delta != null && delta is String) {
|
||||
buffer.write(delta);
|
||||
onStream(buffer.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
client.close();
|
||||
final result = buffer.toString();
|
||||
debugPrint('📥 流式回复完成: ${result.length} 字符');
|
||||
completer.complete(result);
|
||||
},
|
||||
onError: (e) {
|
||||
client.close();
|
||||
debugPrint('❌ 流式请求错误: $e');
|
||||
completer.completeError(e);
|
||||
},
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
} catch (e) {
|
||||
debugPrint('❌ LLM 流式请求失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取角色的问候语
|
||||
static String getGreeting(CharacterModel character) {
|
||||
return character.aiGreeting ??
|
||||
'你好,我是 ${character.name}。有什么我可以帮你的吗?';
|
||||
}
|
||||
}
|
||||
78
wei_ai_app/lib/core/services/chat_storage_service.dart
Normal file
78
wei_ai_app/lib/core/services/chat_storage_service.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/chat_message_model.dart';
|
||||
|
||||
/// 本地聊天存储服务
|
||||
///
|
||||
/// 使用 SharedPreferences 存储聊天记录
|
||||
class ChatStorageService {
|
||||
ChatStorageService._();
|
||||
|
||||
static const String _keyPrefix = 'chat_session_';
|
||||
|
||||
/// 获取聊天会话
|
||||
static Future<ChatSession> getSession(String characterId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = '$_keyPrefix$characterId';
|
||||
final data = prefs.getString(key);
|
||||
|
||||
if (data == null) {
|
||||
return ChatSession.empty(characterId);
|
||||
}
|
||||
|
||||
return ChatSession.fromJson(jsonDecode(data));
|
||||
} catch (e) {
|
||||
debugPrint('❌ 读取聊天记录失败: $e');
|
||||
return ChatSession.empty(characterId);
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存聊天会话
|
||||
static Future<void> saveSession(ChatSession session) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = '$_keyPrefix${session.characterId}';
|
||||
await prefs.setString(key, jsonEncode(session.toJson()));
|
||||
debugPrint('✅ 聊天记录已保存 (${session.messages.length} 条消息)');
|
||||
} catch (e) {
|
||||
debugPrint('❌ 保存聊天记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加消息到会话
|
||||
static Future<ChatSession> addMessage(String characterId, ChatMessage message) async {
|
||||
final session = await getSession(characterId);
|
||||
final newSession = session.addMessage(message);
|
||||
await saveSession(newSession);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
/// 清除角色的聊天记录
|
||||
static Future<void> clearSession(String characterId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = '$_keyPrefix$characterId';
|
||||
await prefs.remove(key);
|
||||
debugPrint('✅ 聊天记录已清除');
|
||||
} catch (e) {
|
||||
debugPrint('❌ 清除聊天记录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有聊天会话的角色 ID 列表
|
||||
static Future<List<String>> getAllSessionIds() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keys = prefs.getKeys();
|
||||
return keys
|
||||
.where((k) => k.startsWith(_keyPrefix))
|
||||
.map((k) => k.substring(_keyPrefix.length))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ 获取会话列表失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
6
wei_ai_app/lib/core/services/services.dart
Normal file
6
wei_ai_app/lib/core/services/services.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
/// Core Services 导出
|
||||
library services;
|
||||
|
||||
export 'supabase_service.dart';
|
||||
export 'chat_service.dart';
|
||||
export 'chat_storage_service.dart';
|
||||
60
wei_ai_app/lib/core/services/supabase_service.dart
Normal file
60
wei_ai_app/lib/core/services/supabase_service.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../config/supabase_config.dart';
|
||||
|
||||
/// Supabase 服务
|
||||
///
|
||||
/// 负责初始化和管理 Supabase 客户端
|
||||
class SupabaseService {
|
||||
SupabaseService._();
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 初始化 Supabase
|
||||
///
|
||||
/// 应该在 main() 函数中调用,确保在 runApp 之前完成
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
debugPrint('⚠️ Supabase 已经初始化过了');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Supabase.initialize(
|
||||
url: SupabaseConfig.url,
|
||||
anonKey: SupabaseConfig.anonKey,
|
||||
debug: SupabaseConfig.debug,
|
||||
);
|
||||
_initialized = true;
|
||||
debugPrint('✅ Supabase 初始化成功');
|
||||
debugPrint(' URL: ${SupabaseConfig.url}');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Supabase 初始化失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Supabase 客户端实例
|
||||
static SupabaseClient get client => Supabase.instance.client;
|
||||
|
||||
/// 获取当前登录用户
|
||||
static User? get currentUser => client.auth.currentUser;
|
||||
|
||||
/// 检查用户是否已登录
|
||||
static bool get isLoggedIn => currentUser != null;
|
||||
|
||||
/// 获取 Auth 实例
|
||||
static GoTrueClient get auth => client.auth;
|
||||
|
||||
/// 获取数据库实例
|
||||
static SupabaseQueryBuilder from(String table) => client.from(table);
|
||||
|
||||
/// 获取存储实例
|
||||
static SupabaseStorageClient get storage => client.storage;
|
||||
|
||||
/// 获取 Realtime 实例
|
||||
static RealtimeClient get realtime => client.realtime;
|
||||
|
||||
/// 获取 Functions 实例
|
||||
static FunctionsClient get functions => client.functions;
|
||||
}
|
||||
Reference in New Issue
Block a user