feat: 角色卡 demo
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- 网络权限 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="wei_ai_app"
|
android:label="wei_ai_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
31
wei_ai_app/lib/core/config/supabase_config.dart
Normal file
31
wei_ai_app/lib/core/config/supabase_config.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// Supabase 配置
|
||||||
|
///
|
||||||
|
/// 这里配置了本地 Docker 部署的 Supabase 连接信息
|
||||||
|
/// 生产环境部署时需要替换为实际的 URL 和 Key
|
||||||
|
class SupabaseConfig {
|
||||||
|
SupabaseConfig._();
|
||||||
|
|
||||||
|
/// Supabase API URL
|
||||||
|
///
|
||||||
|
/// - Android 模拟器: 使用 10.0.2.2 访问主机
|
||||||
|
/// - iOS 模拟器/macOS: 使用 localhost
|
||||||
|
/// - 真机测试: 需要替换为电脑的局域网 IP 地址
|
||||||
|
static String get url {
|
||||||
|
// Android 模拟器需要使用 10.0.2.2 来访问主机
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return 'http://10.0.2.2:8000';
|
||||||
|
}
|
||||||
|
// 其他平台使用 localhost
|
||||||
|
return 'http://localhost:8000';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supabase Anon Key
|
||||||
|
/// 这个 key 是公开的,用于客户端访问
|
||||||
|
static const String anonKey =
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY5NTk1NjI4LCJleHAiOjE5MjcyNzU2Mjh9.moV0JpCSx3Y1QTZmKZ5K-tQLaWcshxtxFlCoIBQFsEU';
|
||||||
|
|
||||||
|
/// 是否启用调试模式
|
||||||
|
static const bool debug = true;
|
||||||
|
}
|
||||||
17
wei_ai_app/lib/core/core.dart
Normal file
17
wei_ai_app/lib/core/core.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/// Core 模块导出
|
||||||
|
///
|
||||||
|
/// 统一导出所有 core 模块的公共 API
|
||||||
|
library core;
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
export 'config/supabase_config.dart';
|
||||||
|
|
||||||
|
// 服务
|
||||||
|
export 'services/services.dart';
|
||||||
|
|
||||||
|
// 模型
|
||||||
|
export 'models/models.dart';
|
||||||
|
|
||||||
|
// 仓库
|
||||||
|
export 'repositories/repositories.dart';
|
||||||
|
|
||||||
43
wei_ai_app/lib/core/models/category_model.dart
Normal file
43
wei_ai_app/lib/core/models/category_model.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/// 分类/筛选模型
|
||||||
|
///
|
||||||
|
/// 对应 Supabase 中的 categories 表
|
||||||
|
class CategoryModel {
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String label;
|
||||||
|
final int sortOrder;
|
||||||
|
final bool isActive;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
const CategoryModel({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.label,
|
||||||
|
this.sortOrder = 0,
|
||||||
|
this.isActive = true,
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CategoryModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CategoryModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
code: json['code'] as String,
|
||||||
|
label: json['label'] as String,
|
||||||
|
sortOrder: json['sort_order'] as int? ?? 0,
|
||||||
|
isActive: json['is_active'] as bool? ?? true,
|
||||||
|
createdAt: json['created_at'] != null
|
||||||
|
? DateTime.parse(json['created_at'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'code': code,
|
||||||
|
'label': label,
|
||||||
|
'sort_order': sortOrder,
|
||||||
|
'is_active': isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
281
wei_ai_app/lib/core/models/character_model.dart
Normal file
281
wei_ai_app/lib/core/models/character_model.dart
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/// AI 性格配置
|
||||||
|
class AiPersonality {
|
||||||
|
final double temperature;
|
||||||
|
final List<String> traits;
|
||||||
|
final String responseStyle;
|
||||||
|
final List<String>? forbiddenTopics;
|
||||||
|
final String? model;
|
||||||
|
final int? maxTokens;
|
||||||
|
|
||||||
|
const AiPersonality({
|
||||||
|
this.temperature = 0.7,
|
||||||
|
this.traits = const [],
|
||||||
|
this.responseStyle = '',
|
||||||
|
this.forbiddenTopics,
|
||||||
|
this.model,
|
||||||
|
this.maxTokens,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AiPersonality.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AiPersonality(
|
||||||
|
temperature: (json['temperature'] as num?)?.toDouble() ?? 0.7,
|
||||||
|
traits: (json['traits'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||||
|
responseStyle: json['response_style'] as String? ?? '',
|
||||||
|
forbiddenTopics: (json['forbidden_topics'] as List<dynamic>?)?.cast<String>(),
|
||||||
|
model: json['model'] as String?,
|
||||||
|
maxTokens: json['max_tokens'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'temperature': temperature,
|
||||||
|
'traits': traits,
|
||||||
|
'response_style': responseStyle,
|
||||||
|
if (forbiddenTopics != null) 'forbidden_topics': forbiddenTopics,
|
||||||
|
if (model != null) 'model': model,
|
||||||
|
if (maxTokens != null) 'max_tokens': maxTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 语音配置
|
||||||
|
class AiVoiceConfig {
|
||||||
|
final String? voiceId;
|
||||||
|
final double speed;
|
||||||
|
final double pitch;
|
||||||
|
final String? emotion;
|
||||||
|
|
||||||
|
const AiVoiceConfig({
|
||||||
|
this.voiceId,
|
||||||
|
this.speed = 1.0,
|
||||||
|
this.pitch = 1.0,
|
||||||
|
this.emotion,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AiVoiceConfig.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AiVoiceConfig(
|
||||||
|
voiceId: json['voice_id'] as String?,
|
||||||
|
speed: (json['speed'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
pitch: (json['pitch'] as num?)?.toDouble() ?? 1.0,
|
||||||
|
emotion: json['emotion'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
if (voiceId != null) 'voice_id': voiceId,
|
||||||
|
'speed': speed,
|
||||||
|
'pitch': pitch,
|
||||||
|
if (emotion != null) 'emotion': emotion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角色标签
|
||||||
|
class CharacterTag {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? color;
|
||||||
|
|
||||||
|
const CharacterTag({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CharacterTag.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CharacterTag(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
color: json['color'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
if (color != null) 'color': color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角色状态枚举
|
||||||
|
enum CharacterStatus {
|
||||||
|
online,
|
||||||
|
busy,
|
||||||
|
offline;
|
||||||
|
|
||||||
|
static CharacterStatus fromString(String? value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'online':
|
||||||
|
return CharacterStatus.online;
|
||||||
|
case 'busy':
|
||||||
|
return CharacterStatus.busy;
|
||||||
|
case 'offline':
|
||||||
|
return CharacterStatus.offline;
|
||||||
|
default:
|
||||||
|
return CharacterStatus.offline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get value {
|
||||||
|
switch (this) {
|
||||||
|
case CharacterStatus.online:
|
||||||
|
return 'online';
|
||||||
|
case CharacterStatus.busy:
|
||||||
|
return 'busy';
|
||||||
|
case CharacterStatus.offline:
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角色模型
|
||||||
|
///
|
||||||
|
/// 对应 Supabase 中的 characters 表
|
||||||
|
class CharacterModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? tagline;
|
||||||
|
final String? avatarPath;
|
||||||
|
final String? description;
|
||||||
|
final CharacterStatus status;
|
||||||
|
final bool isLocked;
|
||||||
|
final bool isActive;
|
||||||
|
final int sortOrder;
|
||||||
|
|
||||||
|
// AI 配置
|
||||||
|
final String? aiSystemPrompt;
|
||||||
|
final String? aiGreeting;
|
||||||
|
final AiPersonality aiPersonality;
|
||||||
|
final AiVoiceConfig aiVoiceConfig;
|
||||||
|
|
||||||
|
// 关联的标签
|
||||||
|
final List<CharacterTag> tags;
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
const CharacterModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.tagline,
|
||||||
|
this.avatarPath,
|
||||||
|
this.description,
|
||||||
|
this.status = CharacterStatus.offline,
|
||||||
|
this.isLocked = false,
|
||||||
|
this.isActive = true,
|
||||||
|
this.sortOrder = 0,
|
||||||
|
this.aiSystemPrompt,
|
||||||
|
this.aiGreeting,
|
||||||
|
this.aiPersonality = const AiPersonality(),
|
||||||
|
this.aiVoiceConfig = const AiVoiceConfig(),
|
||||||
|
this.tags = const [],
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 从 JSON 创建(用于 Supabase 响应解析)
|
||||||
|
factory CharacterModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
// 解析标签 - 可能来自视图的 jsonb 数组或单独查询
|
||||||
|
List<CharacterTag> parseTags(dynamic tagsData) {
|
||||||
|
if (tagsData == null) return [];
|
||||||
|
if (tagsData is List) {
|
||||||
|
return tagsData
|
||||||
|
.where((t) => t != null && t is Map<String, dynamic>)
|
||||||
|
.map((t) => CharacterTag.fromJson(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return CharacterModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
tagline: json['tagline'] as String?,
|
||||||
|
avatarPath: json['avatar_path'] as String?,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
status: CharacterStatus.fromString(json['status'] as String?),
|
||||||
|
isLocked: json['is_locked'] as bool? ?? false,
|
||||||
|
isActive: json['is_active'] as bool? ?? true,
|
||||||
|
sortOrder: json['sort_order'] as int? ?? 0,
|
||||||
|
aiSystemPrompt: json['ai_system_prompt'] as String?,
|
||||||
|
aiGreeting: json['ai_greeting'] as String?,
|
||||||
|
aiPersonality: json['ai_personality'] != null
|
||||||
|
? AiPersonality.fromJson(json['ai_personality'] as Map<String, dynamic>)
|
||||||
|
: const AiPersonality(),
|
||||||
|
aiVoiceConfig: json['ai_voice_config'] != null
|
||||||
|
? AiVoiceConfig.fromJson(json['ai_voice_config'] as Map<String, dynamic>)
|
||||||
|
: const AiVoiceConfig(),
|
||||||
|
tags: parseTags(json['tags']),
|
||||||
|
createdAt: json['created_at'] != null
|
||||||
|
? DateTime.parse(json['created_at'] as String)
|
||||||
|
: null,
|
||||||
|
updatedAt: json['updated_at'] != null
|
||||||
|
? DateTime.parse(json['updated_at'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换为 JSON(用于插入/更新)
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'tagline': tagline,
|
||||||
|
'avatar_path': avatarPath,
|
||||||
|
'description': description,
|
||||||
|
'status': status.value,
|
||||||
|
'is_locked': isLocked,
|
||||||
|
'is_active': isActive,
|
||||||
|
'sort_order': sortOrder,
|
||||||
|
'ai_system_prompt': aiSystemPrompt,
|
||||||
|
'ai_greeting': aiGreeting,
|
||||||
|
'ai_personality': aiPersonality.toJson(),
|
||||||
|
'ai_voice_config': aiVoiceConfig.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取标签名称列表(用于兼容旧代码)
|
||||||
|
List<String> get tagNames => tags.map((t) => t.name).toList();
|
||||||
|
|
||||||
|
/// 复制并修改部分字段
|
||||||
|
CharacterModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? tagline,
|
||||||
|
String? avatarPath,
|
||||||
|
String? description,
|
||||||
|
CharacterStatus? status,
|
||||||
|
bool? isLocked,
|
||||||
|
bool? isActive,
|
||||||
|
int? sortOrder,
|
||||||
|
String? aiSystemPrompt,
|
||||||
|
String? aiGreeting,
|
||||||
|
AiPersonality? aiPersonality,
|
||||||
|
AiVoiceConfig? aiVoiceConfig,
|
||||||
|
List<CharacterTag>? tags,
|
||||||
|
}) {
|
||||||
|
return CharacterModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
tagline: tagline ?? this.tagline,
|
||||||
|
avatarPath: avatarPath ?? this.avatarPath,
|
||||||
|
description: description ?? this.description,
|
||||||
|
status: status ?? this.status,
|
||||||
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
sortOrder: sortOrder ?? this.sortOrder,
|
||||||
|
aiSystemPrompt: aiSystemPrompt ?? this.aiSystemPrompt,
|
||||||
|
aiGreeting: aiGreeting ?? this.aiGreeting,
|
||||||
|
aiPersonality: aiPersonality ?? this.aiPersonality,
|
||||||
|
aiVoiceConfig: aiVoiceConfig ?? this.aiVoiceConfig,
|
||||||
|
tags: tags ?? this.tags,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
wei_ai_app/lib/core/models/chat_message_model.dart
Normal file
127
wei_ai_app/lib/core/models/chat_message_model.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/// 聊天消息模型
|
||||||
|
|
||||||
|
/// 聊天消息模型
|
||||||
|
class ChatMessage {
|
||||||
|
final String id;
|
||||||
|
final String role; // 'user', 'assistant', 'system'
|
||||||
|
final String content;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
const ChatMessage({
|
||||||
|
required this.id,
|
||||||
|
required this.role,
|
||||||
|
required this.content,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ChatMessage.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: json['id'] as String,
|
||||||
|
role: json['role'] as String,
|
||||||
|
content: json['content'] as String,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'role': role,
|
||||||
|
'content': content,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换为 OpenAI API 格式
|
||||||
|
Map<String, String> toApiFormat() {
|
||||||
|
return {
|
||||||
|
'role': role,
|
||||||
|
'content': content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否是用户消息
|
||||||
|
bool get isUser => role == 'user';
|
||||||
|
|
||||||
|
/// 是否是 AI 消息
|
||||||
|
bool get isAssistant => role == 'assistant';
|
||||||
|
|
||||||
|
/// 创建用户消息
|
||||||
|
factory ChatMessage.user(String content) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
role: 'user',
|
||||||
|
content: content,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建 AI 消息
|
||||||
|
factory ChatMessage.assistant(String content) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: content,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建系统消息
|
||||||
|
factory ChatMessage.system(String content) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: 'system',
|
||||||
|
role: 'system',
|
||||||
|
content: content,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 聊天会话模型
|
||||||
|
class ChatSession {
|
||||||
|
final String characterId;
|
||||||
|
final List<ChatMessage> messages;
|
||||||
|
final DateTime lastUpdated;
|
||||||
|
|
||||||
|
const ChatSession({
|
||||||
|
required this.characterId,
|
||||||
|
required this.messages,
|
||||||
|
required this.lastUpdated,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ChatSession.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ChatSession(
|
||||||
|
characterId: json['character_id'] as String,
|
||||||
|
messages: (json['messages'] as List)
|
||||||
|
.map((m) => ChatMessage.fromJson(m))
|
||||||
|
.toList(),
|
||||||
|
lastUpdated: DateTime.parse(json['last_updated'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'character_id': characterId,
|
||||||
|
'messages': messages.map((m) => m.toJson()).toList(),
|
||||||
|
'last_updated': lastUpdated.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建空会话
|
||||||
|
factory ChatSession.empty(String characterId) {
|
||||||
|
return ChatSession(
|
||||||
|
characterId: characterId,
|
||||||
|
messages: [],
|
||||||
|
lastUpdated: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加消息并返回新会话
|
||||||
|
ChatSession addMessage(ChatMessage message) {
|
||||||
|
return ChatSession(
|
||||||
|
characterId: characterId,
|
||||||
|
messages: [...messages, message],
|
||||||
|
lastUpdated: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
wei_ai_app/lib/core/models/llm_config_model.dart
Normal file
73
wei_ai_app/lib/core/models/llm_config_model.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/// LLM 配置模型
|
||||||
|
///
|
||||||
|
/// 对应 Supabase 中的 llm_config 表
|
||||||
|
class LlmConfigModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
// API 连接
|
||||||
|
final String apiBaseUrl;
|
||||||
|
final String apiKey;
|
||||||
|
final String model;
|
||||||
|
|
||||||
|
// 模型参数
|
||||||
|
final double temperature;
|
||||||
|
final int maxTokens;
|
||||||
|
final bool stream;
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
const LlmConfigModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.isActive = true,
|
||||||
|
required this.apiBaseUrl,
|
||||||
|
required this.apiKey,
|
||||||
|
required this.model,
|
||||||
|
this.temperature = 0.7,
|
||||||
|
this.maxTokens = 2048,
|
||||||
|
this.stream = true,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LlmConfigModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LlmConfigModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
isActive: json['is_active'] as bool? ?? true,
|
||||||
|
apiBaseUrl: json['api_base_url'] as String,
|
||||||
|
apiKey: json['api_key'] as String,
|
||||||
|
model: json['model'] as String,
|
||||||
|
temperature: (json['temperature'] as num?)?.toDouble() ?? 0.7,
|
||||||
|
maxTokens: json['max_tokens'] as int? ?? 2048,
|
||||||
|
stream: json['stream'] as bool? ?? true,
|
||||||
|
createdAt: json['created_at'] != null
|
||||||
|
? DateTime.parse(json['created_at'] as String)
|
||||||
|
: null,
|
||||||
|
updatedAt: json['updated_at'] != null
|
||||||
|
? DateTime.parse(json['updated_at'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'is_active': isActive,
|
||||||
|
'api_base_url': apiBaseUrl,
|
||||||
|
'api_key': apiKey,
|
||||||
|
'model': model,
|
||||||
|
'temperature': temperature,
|
||||||
|
'max_tokens': maxTokens,
|
||||||
|
'stream': stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取完整的 API URL(用于聊天完成)
|
||||||
|
String get chatCompletionsUrl => '$apiBaseUrl/chat/completions';
|
||||||
|
}
|
||||||
7
wei_ai_app/lib/core/models/models.dart
Normal file
7
wei_ai_app/lib/core/models/models.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// Core Models 导出
|
||||||
|
library models;
|
||||||
|
|
||||||
|
export 'character_model.dart';
|
||||||
|
export 'category_model.dart';
|
||||||
|
export 'llm_config_model.dart';
|
||||||
|
export 'chat_message_model.dart';
|
||||||
164
wei_ai_app/lib/core/repositories/character_repository.dart
Normal file
164
wei_ai_app/lib/core/repositories/character_repository.dart
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../services/supabase_service.dart';
|
||||||
|
import '../models/character_model.dart';
|
||||||
|
import '../models/category_model.dart';
|
||||||
|
|
||||||
|
/// 角色仓库
|
||||||
|
///
|
||||||
|
/// 负责从 Supabase 获取角色相关数据
|
||||||
|
class CharacterRepository {
|
||||||
|
CharacterRepository._();
|
||||||
|
|
||||||
|
/// 获取所有分类
|
||||||
|
static Future<List<CategoryModel>> getCategories() async {
|
||||||
|
try {
|
||||||
|
final response = await SupabaseService.from('categories')
|
||||||
|
.select()
|
||||||
|
.eq('is_active', true)
|
||||||
|
.order('sort_order', ascending: true);
|
||||||
|
|
||||||
|
return (response as List)
|
||||||
|
.map((json) => CategoryModel.fromJson(json))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 获取分类失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有角色(包含标签)
|
||||||
|
///
|
||||||
|
/// 使用 characters_with_tags 视图获取完整数据
|
||||||
|
static Future<List<CharacterModel>> getCharacters() async {
|
||||||
|
try {
|
||||||
|
final response = await SupabaseService.from('characters_with_tags')
|
||||||
|
.select();
|
||||||
|
|
||||||
|
return (response as List)
|
||||||
|
.map((json) => CharacterModel.fromJson(json))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 获取角色列表失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据分类筛选角色
|
||||||
|
///
|
||||||
|
/// [categoryCode] 分类代码,如 'gentle', 'dom' 等
|
||||||
|
/// 'all' 返回所有角色
|
||||||
|
static Future<List<CharacterModel>> getCharactersByCategory(String categoryCode) async {
|
||||||
|
try {
|
||||||
|
// 如果是 'all',直接获取所有
|
||||||
|
if (categoryCode == 'all') {
|
||||||
|
return getCharacters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该分类下的标签
|
||||||
|
final tagsResponse = await SupabaseService.from('tags')
|
||||||
|
.select('name')
|
||||||
|
.eq('category_id',
|
||||||
|
SupabaseService.from('categories')
|
||||||
|
.select('id')
|
||||||
|
.eq('code', categoryCode)
|
||||||
|
.single()
|
||||||
|
);
|
||||||
|
|
||||||
|
final tagNames = (tagsResponse as List)
|
||||||
|
.map((t) => t['name'] as String)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (tagNames.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取包含这些标签的角色
|
||||||
|
final allCharacters = await getCharacters();
|
||||||
|
|
||||||
|
return allCharacters.where((char) {
|
||||||
|
return char.tagNames.any((tag) => tagNames.contains(tag));
|
||||||
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 按分类获取角色失败: $e');
|
||||||
|
// 如果查询失败,返回本地筛选
|
||||||
|
return _filterCharactersLocally(await getCharacters(), categoryCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地筛选角色(作为后备方案)
|
||||||
|
static List<CharacterModel> _filterCharactersLocally(
|
||||||
|
List<CharacterModel> characters,
|
||||||
|
String categoryCode
|
||||||
|
) {
|
||||||
|
if (categoryCode == 'all') return characters;
|
||||||
|
|
||||||
|
return characters.where((c) {
|
||||||
|
final tags = c.tagNames.join('');
|
||||||
|
switch (categoryCode) {
|
||||||
|
case 'gentle':
|
||||||
|
return tags.contains('治愈') || tags.contains('温顺') || tags.contains('医疗');
|
||||||
|
case 'dom':
|
||||||
|
return tags.contains('强势') || tags.contains('调教') || tags.contains('指令');
|
||||||
|
case 'wild':
|
||||||
|
return tags.contains('病娇') || tags.contains('神秘') ||
|
||||||
|
tags.contains('不稳定') || tags.contains('极乐');
|
||||||
|
case 'exclusive':
|
||||||
|
return c.isLocked;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 ID 获取单个角色
|
||||||
|
static Future<CharacterModel?> getCharacterById(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await SupabaseService.from('characters_with_tags')
|
||||||
|
.select()
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (response == null) return null;
|
||||||
|
return CharacterModel.fromJson(response);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 获取角色详情失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取角色头像的公开 URL
|
||||||
|
///
|
||||||
|
/// [avatarPath] Storage 中的路径
|
||||||
|
static String getAvatarUrl(String? avatarPath) {
|
||||||
|
if (avatarPath == null || avatarPath.isEmpty) {
|
||||||
|
// 返回默认头像
|
||||||
|
return 'https://via.placeholder.com/300x400?text=No+Avatar';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经是完整 URL,直接返回
|
||||||
|
if (avatarPath.startsWith('http')) {
|
||||||
|
return avatarPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Storage 获取公开 URL
|
||||||
|
return SupabaseService.storage
|
||||||
|
.from('avatars')
|
||||||
|
.getPublicUrl(avatarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取仅会员可见的角色
|
||||||
|
static Future<List<CharacterModel>> getLockedCharacters() async {
|
||||||
|
try {
|
||||||
|
final response = await SupabaseService.from('characters_with_tags')
|
||||||
|
.select()
|
||||||
|
.eq('is_locked', true);
|
||||||
|
|
||||||
|
return (response as List)
|
||||||
|
.map((json) => CharacterModel.fromJson(json))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 获取会员角色失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
wei_ai_app/lib/core/repositories/llm_config_repository.dart
Normal file
60
wei_ai_app/lib/core/repositories/llm_config_repository.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../services/supabase_service.dart';
|
||||||
|
import '../models/llm_config_model.dart';
|
||||||
|
|
||||||
|
/// LLM 配置仓库
|
||||||
|
///
|
||||||
|
/// 负责从 Supabase 获取 LLM 配置
|
||||||
|
class LlmConfigRepository {
|
||||||
|
LlmConfigRepository._();
|
||||||
|
|
||||||
|
static LlmConfigModel? _cachedConfig;
|
||||||
|
|
||||||
|
/// 获取当前激活的 LLM 配置
|
||||||
|
///
|
||||||
|
/// 开发阶段默认强制刷新,生产环境可改为 false 使用缓存
|
||||||
|
static Future<LlmConfigModel?> getActiveConfig({bool forceRefresh = true}) async {
|
||||||
|
if (_cachedConfig != null && !forceRefresh) {
|
||||||
|
return _cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await SupabaseService.from('llm_config')
|
||||||
|
.select()
|
||||||
|
.eq('is_active', true)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
debugPrint('⚠️ 没有找到激活的 LLM 配置');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cachedConfig = LlmConfigModel.fromJson(response);
|
||||||
|
debugPrint('✅ LLM 配置已加载: ${_cachedConfig!.model}');
|
||||||
|
return _cachedConfig;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 获取 LLM 配置失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除缓存
|
||||||
|
static void clearCache() {
|
||||||
|
_cachedConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新配置(需要管理员权限)
|
||||||
|
static Future<void> updateConfig(LlmConfigModel config) async {
|
||||||
|
try {
|
||||||
|
await SupabaseService.from('llm_config')
|
||||||
|
.update(config.toJson())
|
||||||
|
.eq('id', config.id);
|
||||||
|
|
||||||
|
_cachedConfig = config;
|
||||||
|
debugPrint('✅ LLM 配置已更新');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 更新 LLM 配置失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
wei_ai_app/lib/core/repositories/repositories.dart
Normal file
5
wei_ai_app/lib/core/repositories/repositories.dart
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// Core Repositories 导出
|
||||||
|
library repositories;
|
||||||
|
|
||||||
|
export 'character_repository.dart';
|
||||||
|
export 'llm_config_repository.dart';
|
||||||
183
wei_ai_app/lib/core/services/chat_service.dart
Normal file
183
wei_ai_app/lib/core/services/chat_service.dart
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../models/chat_message_model.dart';
|
||||||
|
import '../models/llm_config_model.dart';
|
||||||
|
import '../models/character_model.dart';
|
||||||
|
import '../repositories/llm_config_repository.dart';
|
||||||
|
|
||||||
|
/// AI 聊天服务
|
||||||
|
///
|
||||||
|
/// 负责调用 LLM API 进行对话
|
||||||
|
class ChatService {
|
||||||
|
ChatService._();
|
||||||
|
|
||||||
|
/// 发送消息并获取 AI 回复
|
||||||
|
///
|
||||||
|
/// [character] 当前对话的角色
|
||||||
|
/// [messages] 历史消息列表
|
||||||
|
/// [userMessage] 用户新发送的消息
|
||||||
|
/// [onStream] 流式输出回调(每次收到新内容时调用)
|
||||||
|
static Future<String> sendMessage({
|
||||||
|
required CharacterModel character,
|
||||||
|
required List<ChatMessage> messages,
|
||||||
|
required String userMessage,
|
||||||
|
Function(String)? onStream,
|
||||||
|
}) async {
|
||||||
|
// 获取 LLM 配置
|
||||||
|
final config = await LlmConfigRepository.getActiveConfig();
|
||||||
|
if (config == null) {
|
||||||
|
throw Exception('LLM 配置未找到,请先配置 API Key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建消息列表
|
||||||
|
final apiMessages = <Map<String, String>>[];
|
||||||
|
|
||||||
|
// 1. System prompt(角色人设)
|
||||||
|
final systemPrompt = character.aiSystemPrompt ??
|
||||||
|
'你是 ${character.name},${character.tagline ?? "一个AI角色"}。${character.description ?? ""}';
|
||||||
|
apiMessages.add({'role': 'system', 'content': systemPrompt});
|
||||||
|
|
||||||
|
// 2. 历史消息(最多保留最近 20 条)
|
||||||
|
final recentMessages = messages.length > 20
|
||||||
|
? messages.sublist(messages.length - 20)
|
||||||
|
: messages;
|
||||||
|
for (final msg in recentMessages) {
|
||||||
|
if (msg.role != 'system') {
|
||||||
|
apiMessages.add(msg.toApiFormat());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 新用户消息
|
||||||
|
apiMessages.add({'role': 'user', 'content': userMessage});
|
||||||
|
|
||||||
|
debugPrint('📤 发送到 LLM: ${apiMessages.length} 条消息');
|
||||||
|
|
||||||
|
// 判断是否使用流式输出
|
||||||
|
if (config.stream && onStream != null) {
|
||||||
|
return _sendStreamRequest(config, apiMessages, onStream);
|
||||||
|
} else {
|
||||||
|
return _sendNormalRequest(config, apiMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 普通请求(非流式)
|
||||||
|
static Future<String> _sendNormalRequest(
|
||||||
|
LlmConfigModel config,
|
||||||
|
List<Map<String, String>> messages,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(config.chatCompletionsUrl),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ${config.apiKey}',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': config.model,
|
||||||
|
'messages': messages,
|
||||||
|
'temperature': config.temperature,
|
||||||
|
'max_tokens': config.maxTokens,
|
||||||
|
'stream': false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
debugPrint('❌ LLM API 错误: ${response.statusCode} ${response.body}');
|
||||||
|
throw Exception('API 请求失败: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final content = data['choices'][0]['message']['content'] as String;
|
||||||
|
debugPrint('📥 收到回复: ${content.substring(0, content.length.clamp(0, 50))}...');
|
||||||
|
return content;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ LLM 请求失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式请求
|
||||||
|
static Future<String> _sendStreamRequest(
|
||||||
|
LlmConfigModel config,
|
||||||
|
List<Map<String, String>> messages,
|
||||||
|
Function(String) onStream,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final client = http.Client();
|
||||||
|
final request = http.Request('POST', Uri.parse(config.chatCompletionsUrl));
|
||||||
|
|
||||||
|
request.headers.addAll({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ${config.apiKey}',
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
request.body = jsonEncode({
|
||||||
|
'model': config.model,
|
||||||
|
'messages': messages,
|
||||||
|
'temperature': config.temperature,
|
||||||
|
'max_tokens': config.maxTokens,
|
||||||
|
'stream': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await client.send(request);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final body = await response.stream.bytesToString();
|
||||||
|
debugPrint('❌ LLM API 错误: ${response.statusCode} $body');
|
||||||
|
throw Exception('API 请求失败: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final completer = Completer<String>();
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
response.stream
|
||||||
|
.transform(utf8.decoder)
|
||||||
|
.transform(const LineSplitter())
|
||||||
|
.listen(
|
||||||
|
(line) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
final data = line.substring(6);
|
||||||
|
if (data == '[DONE]') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(data);
|
||||||
|
final delta = json['choices']?[0]?['delta']?['content'];
|
||||||
|
if (delta != null && delta is String) {
|
||||||
|
buffer.write(delta);
|
||||||
|
onStream(buffer.toString());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
client.close();
|
||||||
|
final result = buffer.toString();
|
||||||
|
debugPrint('📥 流式回复完成: ${result.length} 字符');
|
||||||
|
completer.complete(result);
|
||||||
|
},
|
||||||
|
onError: (e) {
|
||||||
|
client.close();
|
||||||
|
debugPrint('❌ 流式请求错误: $e');
|
||||||
|
completer.completeError(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ LLM 流式请求失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取角色的问候语
|
||||||
|
static String getGreeting(CharacterModel character) {
|
||||||
|
return character.aiGreeting ??
|
||||||
|
'你好,我是 ${character.name}。有什么我可以帮你的吗?';
|
||||||
|
}
|
||||||
|
}
|
||||||
78
wei_ai_app/lib/core/services/chat_storage_service.dart
Normal file
78
wei_ai_app/lib/core/services/chat_storage_service.dart
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../models/chat_message_model.dart';
|
||||||
|
|
||||||
|
/// 本地聊天存储服务
|
||||||
|
///
|
||||||
|
/// 使用 SharedPreferences 存储聊天记录
|
||||||
|
class ChatStorageService {
|
||||||
|
ChatStorageService._();
|
||||||
|
|
||||||
|
static const String _keyPrefix = 'chat_session_';
|
||||||
|
|
||||||
|
/// 获取聊天会话
|
||||||
|
static Future<ChatSession> getSession(String characterId) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_keyPrefix$characterId';
|
||||||
|
final data = prefs.getString(key);
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
return ChatSession.empty(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatSession.fromJson(jsonDecode(data));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 读取聊天记录失败: $e');
|
||||||
|
return ChatSession.empty(characterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存聊天会话
|
||||||
|
static Future<void> saveSession(ChatSession session) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_keyPrefix${session.characterId}';
|
||||||
|
await prefs.setString(key, jsonEncode(session.toJson()));
|
||||||
|
debugPrint('✅ 聊天记录已保存 (${session.messages.length} 条消息)');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 保存聊天记录失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加消息到会话
|
||||||
|
static Future<ChatSession> addMessage(String characterId, ChatMessage message) async {
|
||||||
|
final session = await getSession(characterId);
|
||||||
|
final newSession = session.addMessage(message);
|
||||||
|
await saveSession(newSession);
|
||||||
|
return newSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除角色的聊天记录
|
||||||
|
static Future<void> clearSession(String characterId) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = '$_keyPrefix$characterId';
|
||||||
|
await prefs.remove(key);
|
||||||
|
debugPrint('✅ 聊天记录已清除');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 清除聊天记录失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有聊天会话的角色 ID 列表
|
||||||
|
static Future<List<String>> getAllSessionIds() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final keys = prefs.getKeys();
|
||||||
|
return keys
|
||||||
|
.where((k) => k.startsWith(_keyPrefix))
|
||||||
|
.map((k) => k.substring(_keyPrefix.length))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ 获取会话列表失败: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
wei_ai_app/lib/core/services/services.dart
Normal file
6
wei_ai_app/lib/core/services/services.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// Core Services 导出
|
||||||
|
library services;
|
||||||
|
|
||||||
|
export 'supabase_service.dart';
|
||||||
|
export 'chat_service.dart';
|
||||||
|
export 'chat_storage_service.dart';
|
||||||
60
wei_ai_app/lib/core/services/supabase_service.dart
Normal file
60
wei_ai_app/lib/core/services/supabase_service.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import '../config/supabase_config.dart';
|
||||||
|
|
||||||
|
/// Supabase 服务
|
||||||
|
///
|
||||||
|
/// 负责初始化和管理 Supabase 客户端
|
||||||
|
class SupabaseService {
|
||||||
|
SupabaseService._();
|
||||||
|
|
||||||
|
static bool _initialized = false;
|
||||||
|
|
||||||
|
/// 初始化 Supabase
|
||||||
|
///
|
||||||
|
/// 应该在 main() 函数中调用,确保在 runApp 之前完成
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_initialized) {
|
||||||
|
debugPrint('⚠️ Supabase 已经初始化过了');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Supabase.initialize(
|
||||||
|
url: SupabaseConfig.url,
|
||||||
|
anonKey: SupabaseConfig.anonKey,
|
||||||
|
debug: SupabaseConfig.debug,
|
||||||
|
);
|
||||||
|
_initialized = true;
|
||||||
|
debugPrint('✅ Supabase 初始化成功');
|
||||||
|
debugPrint(' URL: ${SupabaseConfig.url}');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ Supabase 初始化失败: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Supabase 客户端实例
|
||||||
|
static SupabaseClient get client => Supabase.instance.client;
|
||||||
|
|
||||||
|
/// 获取当前登录用户
|
||||||
|
static User? get currentUser => client.auth.currentUser;
|
||||||
|
|
||||||
|
/// 检查用户是否已登录
|
||||||
|
static bool get isLoggedIn => currentUser != null;
|
||||||
|
|
||||||
|
/// 获取 Auth 实例
|
||||||
|
static GoTrueClient get auth => client.auth;
|
||||||
|
|
||||||
|
/// 获取数据库实例
|
||||||
|
static SupabaseQueryBuilder from(String table) => client.from(table);
|
||||||
|
|
||||||
|
/// 获取存储实例
|
||||||
|
static SupabaseStorageClient get storage => client.storage;
|
||||||
|
|
||||||
|
/// 获取 Realtime 实例
|
||||||
|
static RealtimeClient get realtime => client.realtime;
|
||||||
|
|
||||||
|
/// 获取 Functions 实例
|
||||||
|
static FunctionsClient get functions => client.functions;
|
||||||
|
}
|
||||||
@@ -2,8 +2,15 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'config/theme.dart';
|
import 'config/theme.dart';
|
||||||
import 'router/app_router.dart';
|
import 'router/app_router.dart';
|
||||||
|
import 'core/services/supabase_service.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
// 确保 Flutter 绑定初始化
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// 初始化 Supabase
|
||||||
|
await SupabaseService.initialize();
|
||||||
|
|
||||||
void main() {
|
|
||||||
runApp(const ProviderScope(child: WeiAiApp()));
|
runApp(const ProviderScope(child: WeiAiApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
wei_ai_app/lib/providers/character_providers.dart
Normal file
68
wei_ai_app/lib/providers/character_providers.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../core/core.dart';
|
||||||
|
|
||||||
|
/// 分类列表 Provider
|
||||||
|
final categoriesProvider = FutureProvider<List<CategoryModel>>((ref) async {
|
||||||
|
return CharacterRepository.getCategories();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 角色列表 Provider
|
||||||
|
final charactersProvider = FutureProvider<List<CharacterModel>>((ref) async {
|
||||||
|
return CharacterRepository.getCharacters();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 当前选中的分类代码 Notifier
|
||||||
|
class SelectedCategoryNotifier extends Notifier<String> {
|
||||||
|
@override
|
||||||
|
String build() => 'all';
|
||||||
|
|
||||||
|
void setCategory(String code) {
|
||||||
|
state = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选中的分类代码
|
||||||
|
final selectedCategoryProvider = NotifierProvider<SelectedCategoryNotifier, String>(
|
||||||
|
SelectedCategoryNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 根据分类筛选后的角色列表
|
||||||
|
final filteredCharactersProvider = Provider<AsyncValue<List<CharacterModel>>>((ref) {
|
||||||
|
final categoryCode = ref.watch(selectedCategoryProvider);
|
||||||
|
final charactersAsync = ref.watch(charactersProvider);
|
||||||
|
|
||||||
|
return charactersAsync.when(
|
||||||
|
data: (characters) {
|
||||||
|
if (categoryCode == 'all') {
|
||||||
|
return AsyncValue.data(characters);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地筛选
|
||||||
|
final filtered = characters.where((c) {
|
||||||
|
final tags = c.tagNames.join('');
|
||||||
|
switch (categoryCode) {
|
||||||
|
case 'gentle':
|
||||||
|
return tags.contains('治愈') || tags.contains('温顺') || tags.contains('医疗');
|
||||||
|
case 'dom':
|
||||||
|
return tags.contains('强势') || tags.contains('调教') || tags.contains('指令');
|
||||||
|
case 'wild':
|
||||||
|
return tags.contains('病娇') || tags.contains('神秘') ||
|
||||||
|
tags.contains('不稳定') || tags.contains('极乐');
|
||||||
|
case 'exclusive':
|
||||||
|
return c.isLocked;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return AsyncValue.data(filtered);
|
||||||
|
},
|
||||||
|
loading: () => const AsyncValue.loading(),
|
||||||
|
error: (e, st) => AsyncValue.error(e, st),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 单个角色详情 Provider
|
||||||
|
final characterDetailProvider = FutureProvider.family<CharacterModel?, String>((ref, id) async {
|
||||||
|
return CharacterRepository.getCharacterById(id);
|
||||||
|
});
|
||||||
4
wei_ai_app/lib/providers/providers.dart
Normal file
4
wei_ai_app/lib/providers/providers.dart
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/// Providers 导出
|
||||||
|
library providers;
|
||||||
|
|
||||||
|
export 'character_providers.dart';
|
||||||
@@ -1,64 +1,41 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../data/mock_data.dart';
|
import '../../core/core.dart';
|
||||||
import '../../models/character.dart';
|
import '../../providers/providers.dart';
|
||||||
import '../../widgets/tab_content_layout.dart';
|
import '../../widgets/tab_content_layout.dart';
|
||||||
|
|
||||||
class DiscoveryScreen extends StatefulWidget {
|
class DiscoveryScreen extends ConsumerWidget {
|
||||||
const DiscoveryScreen({super.key});
|
const DiscoveryScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
final categoriesAsync = ref.watch(categoriesProvider);
|
||||||
|
final filteredCharactersAsync = ref.watch(filteredCharactersProvider);
|
||||||
|
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||||
|
|
||||||
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|
||||||
String _activeFilter = 'all';
|
|
||||||
|
|
||||||
final List<Map<String, String>> _filters = [
|
|
||||||
{'id': 'all', 'label': '全部'},
|
|
||||||
{'id': 'gentle', 'label': '温柔治愈'},
|
|
||||||
{'id': 'dom', 'label': '主导强势'},
|
|
||||||
{'id': 'wild', 'label': '反差/猎奇'},
|
|
||||||
{'id': 'voice', 'label': '语音陪聊'},
|
|
||||||
{'id': 'scenario', 'label': '场景扮演'},
|
|
||||||
{'id': 'exclusive', 'label': '会员限定'},
|
|
||||||
];
|
|
||||||
|
|
||||||
List<Character> get _filteredCharacters {
|
|
||||||
if (_activeFilter == 'all') return mockCharacters;
|
|
||||||
return mockCharacters.where((c) {
|
|
||||||
final tags = c.tags.join('');
|
|
||||||
if (_activeFilter == 'gentle') return tags.contains('治愈') || tags.contains('温顺') || tags.contains('医疗');
|
|
||||||
if (_activeFilter == 'dom') return tags.contains('强势') || tags.contains('调教') || tags.contains('指令');
|
|
||||||
if (_activeFilter == 'wild') return tags.contains('病娇') || tags.contains('神秘') || tags.contains('不稳定') || tags.contains('极乐');
|
|
||||||
if (_activeFilter == 'exclusive') return c.isLocked;
|
|
||||||
return false;
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
const double bottomNavHeight = 90;
|
const double bottomNavHeight = 90;
|
||||||
|
|
||||||
return TabContentLayout(
|
return TabContentLayout(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 1. Sticky Filter Bar (simulated)
|
// 1. Filter Bar
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ListView.separated(
|
child: categoriesAsync.when(
|
||||||
|
data: (categories) => ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: _filters.length,
|
itemCount: categories.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final filter = _filters[index];
|
final category = categories[index];
|
||||||
final isActive = _activeFilter == filter['id'];
|
final isActive = selectedCategory == category.code;
|
||||||
return Center(
|
return Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => setState(() => _activeFilter = filter['id']!),
|
onTap: () => ref.read(selectedCategoryProvider.notifier).setCategory(category.code),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
@@ -70,7 +47,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
filter['label']!,
|
category.label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||||
@@ -82,23 +59,27 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
error: (e, _) => Center(child: Text('加载分类失败', style: TextStyle(color: Colors.white.withOpacity(0.5)))),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 2. Grid Layout
|
// 2. Grid Layout
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _filteredCharacters.isEmpty
|
child: filteredCharactersAsync.when(
|
||||||
|
data: (characters) => characters.isEmpty
|
||||||
? Center(child: Text('暂无匹配角色', style: TextStyle(color: Colors.white.withOpacity(0.5))))
|
? Center(child: Text('暂无匹配角色', style: TextStyle(color: Colors.white.withOpacity(0.5))))
|
||||||
: GridView.builder(
|
: GridView.builder(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, bottomNavHeight + 20),
|
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomNavHeight + 20),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
childAspectRatio: 3 / 4,
|
childAspectRatio: 3 / 4,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
),
|
),
|
||||||
itemCount: _filteredCharacters.length,
|
itemCount: characters.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final char = _filteredCharacters[index];
|
final char = characters[index];
|
||||||
return _CharacterCard(
|
return _CharacterCard(
|
||||||
character: char,
|
character: char,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -112,6 +93,34 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
.scale(begin: const Offset(0.9, 0.9));
|
.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('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -120,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CharacterCard extends StatelessWidget {
|
class _CharacterCard extends StatelessWidget {
|
||||||
final Character character;
|
final CharacterModel character;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _CharacterCard({
|
const _CharacterCard({
|
||||||
@@ -130,6 +139,9 @@ class _CharacterCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// 获取头像 URL
|
||||||
|
final avatarUrl = CharacterRepository.getAvatarUrl(character.avatarPath);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -151,7 +163,7 @@ class _CharacterCard extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Clipped content area - ensures all elements respect border radius
|
// Clipped content area
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -159,7 +171,7 @@ class _CharacterCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Background Image
|
// Background Image
|
||||||
Image.network(
|
Image.network(
|
||||||
character.avatar,
|
avatarUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
loadingBuilder: (context, child, loadingProgress) {
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
if (loadingProgress == null) return child;
|
if (loadingProgress == null) return child;
|
||||||
@@ -168,6 +180,22 @@ class _CharacterCard extends StatelessWidget {
|
|||||||
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
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
|
// Gradient Overlay
|
||||||
@@ -179,44 +207,13 @@ class _CharacterCard extends StatelessWidget {
|
|||||||
colors: [
|
colors: [
|
||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
Color(0xFF2E1065), // Deep purple at bottom
|
Color(0xFF2E1065),
|
||||||
],
|
],
|
||||||
stops: [0.0, 0.5, 1.0],
|
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
|
// Top Right: Lock Icon
|
||||||
if (character.isLocked)
|
if (character.isLocked)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -270,7 +267,7 @@ class _CharacterCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tag,
|
tag.name,
|
||||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
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(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|||||||
@@ -2,69 +2,162 @@ import 'dart:ui';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../models/character.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../models/message.dart';
|
import '../../core/core.dart';
|
||||||
import '../../data/mock_data.dart';
|
|
||||||
import 'voice_mode_overlay.dart';
|
import 'voice_mode_overlay.dart';
|
||||||
|
|
||||||
class InteractionScreen extends StatefulWidget {
|
class InteractionScreen extends ConsumerStatefulWidget {
|
||||||
final String characterId;
|
final String characterId;
|
||||||
|
|
||||||
const InteractionScreen({super.key, required this.characterId});
|
const InteractionScreen({super.key, required this.characterId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InteractionScreen> createState() => _InteractionScreenState();
|
ConsumerState<InteractionScreen> createState() => _InteractionScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InteractionScreenState extends State<InteractionScreen> {
|
class _InteractionScreenState extends ConsumerState<InteractionScreen> {
|
||||||
late Character _character;
|
CharacterModel? _character;
|
||||||
final List<Message> _messages = List.from(mockMessages);
|
List<ChatMessage> _messages = [];
|
||||||
final TextEditingController _controller = TextEditingController();
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
bool _isVoiceMode = false;
|
bool _isVoiceMode = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isTyping = false;
|
||||||
|
String _typingContent = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_character = mockCharacters.firstWhere(
|
_loadCharacterAndMessages();
|
||||||
(c) => c.id == widget.characterId,
|
|
||||||
orElse: () => mockCharacters.first,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendMessage() {
|
@override
|
||||||
if (_controller.text.trim().isEmpty) return;
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
final newUserMsg = Message(
|
Future<void> _loadCharacterAndMessages() async {
|
||||||
id: DateTime.now().toString(),
|
// 加载角色信息
|
||||||
text: _controller.text,
|
final character = await CharacterRepository.getCharacterById(widget.characterId);
|
||||||
sender: MessageSender.user,
|
if (character == null) {
|
||||||
type: MessageType.text,
|
debugPrint('❌ 角色不存在: ${widget.characterId}');
|
||||||
timestamp: DateTime.now()
|
if (mounted) context.pop();
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载本地聊天记录
|
||||||
|
final session = await ChatStorageService.getSession(widget.characterId);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages.add(newUserMsg);
|
_character = character;
|
||||||
|
_messages = session.messages;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果没有消息,添加问候语
|
||||||
|
if (_messages.isEmpty) {
|
||||||
|
final greeting = ChatService.getGreeting(character);
|
||||||
|
final greetingMessage = ChatMessage.assistant(greeting);
|
||||||
|
setState(() {
|
||||||
|
_messages = [greetingMessage];
|
||||||
|
});
|
||||||
|
await ChatStorageService.addMessage(widget.characterId, greetingMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMessage() async {
|
||||||
|
if (_controller.text.trim().isEmpty || _character == null || _isLoading) return;
|
||||||
|
|
||||||
|
final userText = _controller.text.trim();
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
});
|
|
||||||
|
|
||||||
// Mock AI Reply
|
// 添加用户消息
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
final userMessage = ChatMessage.user(userText);
|
||||||
if (!mounted) return;
|
setState(() {
|
||||||
final newAiMsg = Message(
|
_messages = [..._messages, userMessage];
|
||||||
id: DateTime.now().toString(),
|
_isLoading = true;
|
||||||
text: '我收到了你的信号: "${newUserMsg.text}"。这让我感觉很好...',
|
_isTyping = true;
|
||||||
sender: MessageSender.ai,
|
_typingContent = '';
|
||||||
type: MessageType.text,
|
});
|
||||||
timestamp: DateTime.now()
|
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(() {
|
setState(() {
|
||||||
_messages.add(newAiMsg);
|
_isTyping = false;
|
||||||
|
_typingContent = '';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -85,27 +178,55 @@ class _InteractionScreenState extends State<InteractionScreen> {
|
|||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
backgroundImage: NetworkImage(_character.avatar),
|
backgroundImage: NetworkImage(avatarUrl),
|
||||||
radius: 16,
|
radius: 16,
|
||||||
|
onBackgroundImageError: (_, __) {},
|
||||||
|
child: _character!.avatarPath == null
|
||||||
|
? const Icon(LucideIcons.user, size: 16, color: Colors.white54)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Column(
|
Expanded(
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(_character.name, style: const TextStyle(fontSize: 16, color: Colors.white)),
|
Text(
|
||||||
|
_character!.name,
|
||||||
|
style: const TextStyle(fontSize: 16, color: Colors.white),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFF10B981), shape: BoxShape.circle)),
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getStatusColor(_character!.status),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('Online', style: TextStyle(fontSize: 10, color: Colors.white.withOpacity(0.7))),
|
Text(
|
||||||
|
_getStatusText(_character!.status),
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.white.withOpacity(0.7)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(icon: const Icon(LucideIcons.moreVertical, color: Colors.white), onPressed: () {}),
|
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(
|
body: Container(
|
||||||
@@ -115,22 +236,64 @@ class _InteractionScreenState extends State<InteractionScreen> {
|
|||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
|
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.only(top: 120, bottom: 20, left: 16, right: 16),
|
padding: const EdgeInsets.only(top: 120, bottom: 20, left: 16, right: 16),
|
||||||
itemCount: _messages.length,
|
itemCount: _messages.length + (_isTyping ? 1 : 0),
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
// 如果是正在输入的消息
|
||||||
|
if (_isTyping && index == _messages.length) {
|
||||||
|
return _buildTypingBubble();
|
||||||
|
}
|
||||||
|
|
||||||
final msg = _messages[index];
|
final msg = _messages[index];
|
||||||
final isMe = msg.sender == MessageSender.user;
|
return _buildMessageBubble(msg, avatarUrl);
|
||||||
return Align(
|
},
|
||||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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(
|
child: Container(
|
||||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.7,
|
||||||
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isMe ? const Color(0xFFA855F7) : Colors.white.withOpacity(0.1),
|
color: isMe ? const Color(0xFFA855F7) : Colors.white.withOpacity(0.1),
|
||||||
@@ -143,17 +306,84 @@ class _InteractionScreenState extends State<InteractionScreen> {
|
|||||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
msg.text,
|
msg.content,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
|
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (isMe) const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTypingBubble() {
|
||||||
|
final avatarUrl = CharacterRepository.getAvatarUrl(_character!.avatarPath);
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundImage: NetworkImage(avatarUrl),
|
||||||
|
radius: 16,
|
||||||
|
onBackgroundImageError: (_, __) {},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.7,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.1),
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(16),
|
||||||
|
topRight: Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(2),
|
||||||
|
bottomRight: Radius.circular(16),
|
||||||
|
),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||||
|
),
|
||||||
|
child: _typingContent.isEmpty
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildDot(0),
|
||||||
|
_buildDot(1),
|
||||||
|
_buildDot(2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_typingContent,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDot(int index) {
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 0.3, end: 1.0),
|
||||||
|
duration: Duration(milliseconds: 600 + index * 200),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(value),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
|
|
||||||
// Input Area
|
Widget _buildInputArea() {
|
||||||
ClipRRect(
|
return ClipRRect(
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -161,7 +391,7 @@ class _InteractionScreenState extends State<InteractionScreen> {
|
|||||||
bottom: MediaQuery.of(context).padding.bottom + 10,
|
bottom: MediaQuery.of(context).padding.bottom + 10,
|
||||||
top: 10,
|
top: 10,
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16
|
right: 16,
|
||||||
),
|
),
|
||||||
color: Colors.black.withOpacity(0.4),
|
color: Colors.black.withOpacity(0.4),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -174,7 +404,7 @@ class _InteractionScreenState extends State<InteractionScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: Colors.white.withOpacity(0.1),
|
color: Colors.white.withOpacity(0.1),
|
||||||
border: Border.all(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),
|
child: const Icon(LucideIcons.phone, color: Colors.white, size: 20),
|
||||||
),
|
),
|
||||||
@@ -184,10 +414,12 @@ class _InteractionScreenState extends State<InteractionScreen> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
|
enabled: !_isLoading,
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white.withOpacity(0.05),
|
fillColor: Colors.white.withOpacity(0.05),
|
||||||
hintText: 'Type a message...',
|
hintText: _isLoading ? 'AI 正在思考...' : '输入消息...',
|
||||||
hintStyle: TextStyle(color: Colors.white.withOpacity(0.3)),
|
hintStyle: TextStyle(color: Colors.white.withOpacity(0.3)),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@@ -199,33 +431,86 @@ class _InteractionScreenState extends State<InteractionScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _sendMessage,
|
onTap: _isLoading ? null : _sendMessage,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(colors: [Color(0xFFA855F7), Color(0xFFEC4899)])
|
gradient: _isLoading
|
||||||
|
? null
|
||||||
|
: const LinearGradient(colors: [Color(0xFFA855F7), Color(0xFFEC4899)]),
|
||||||
|
color: _isLoading ? Colors.white.withOpacity(0.1) : null,
|
||||||
),
|
),
|
||||||
child: const Icon(LucideIcons.send, color: Colors.white, size: 20),
|
child: _isLoading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white54,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
: const Icon(LucideIcons.send, color: Colors.white, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
],
|
|
||||||
), // Column
|
|
||||||
), // Container
|
|
||||||
), // Scaffold
|
|
||||||
|
|
||||||
if (_isVoiceMode)
|
|
||||||
VoiceModeOverlay(
|
|
||||||
character: _character,
|
|
||||||
onClose: () => setState(() => _isVoiceMode = false),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _clearChat() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF2E1065),
|
||||||
|
title: const Text('清除聊天记录', style: TextStyle(color: Colors.white)),
|
||||||
|
content: const Text('确定要清除所有聊天记录吗?此操作不可恢复。',
|
||||||
|
style: TextStyle(color: Colors.white70)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('取消', style: TextStyle(color: Colors.white54)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('清除', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await ChatStorageService.clearSession(widget.characterId);
|
||||||
|
final greeting = ChatService.getGreeting(_character!);
|
||||||
|
final greetingMessage = ChatMessage.assistant(greeting);
|
||||||
|
setState(() {
|
||||||
|
_messages = [greetingMessage];
|
||||||
|
});
|
||||||
|
await ChatStorageService.addMessage(widget.characterId, greetingMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getStatusColor(CharacterStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case CharacterStatus.online:
|
||||||
|
return const Color(0xFF10B981);
|
||||||
|
case CharacterStatus.busy:
|
||||||
|
return const Color(0xFFF59E0B);
|
||||||
|
case CharacterStatus.offline:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getStatusText(CharacterStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case CharacterStatus.online:
|
||||||
|
return '在线';
|
||||||
|
case CharacterStatus.busy:
|
||||||
|
return '忙碌';
|
||||||
|
case CharacterStatus.offline:
|
||||||
|
return '离线';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../models/character.dart';
|
import '../../core/core.dart';
|
||||||
|
|
||||||
class VoiceModeOverlay extends StatefulWidget {
|
class VoiceModeOverlay extends StatefulWidget {
|
||||||
final Character character;
|
final CharacterModel character;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
|
||||||
const VoiceModeOverlay({
|
const VoiceModeOverlay({
|
||||||
@@ -23,6 +22,8 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
|
|||||||
bool _isSpeakerOn = true;
|
bool _isSpeakerOn = true;
|
||||||
late AnimationController _controller;
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
String get _avatarUrl => CharacterRepository.getAvatarUrl(widget.character.avatarPath);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -49,7 +50,7 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
|
|||||||
// Background Image with Blur
|
// Background Image with Blur
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.network(
|
child: Image.network(
|
||||||
widget.character.avatar,
|
_avatarUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return Container(color: const Color(0xFF2E1065));
|
return Container(color: const Color(0xFF2E1065));
|
||||||
@@ -152,7 +153,7 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
image: DecorationImage(
|
image: DecorationImage(
|
||||||
image: NetworkImage(widget.character.avatar),
|
image: NetworkImage(_avatarUrl),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import app_links
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
|
import shared_preferences_foundation
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
42
wei_ai_app/macos/Podfile.lock
Normal file
42
wei_ai_app/macos/Podfile.lock
Normal file
@@ -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
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -60,11 +62,12 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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 = "<group>"; };
|
||||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||||
@@ -76,8 +79,15 @@
|
|||||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
DE48C0152D04F60FAEFF8310 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -85,6 +95,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
3B96C17BA68885E8E6867A23 /* Pods_RunnerTests.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -92,6 +103,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
9FE5C1CDD9E5839EEBD1E649 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -125,6 +137,7 @@
|
|||||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||||
33CC10EE2044A3C60003C045 /* Products */,
|
33CC10EE2044A3C60003C045 /* Products */,
|
||||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||||
|
57F7420864D4D613F3F760A5 /* Pods */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -172,9 +185,25 @@
|
|||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
4229AED7F305AB653234CC67 /* Pods_Runner.framework */,
|
||||||
|
DE48C0152D04F60FAEFF8310 /* Pods_RunnerTests.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -186,6 +215,7 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
4E45BDAD82BDB3FC0D820E95 /* [CP] Check Pods Manifest.lock */,
|
||||||
331C80D1294CF70F00263BE5 /* Sources */,
|
331C80D1294CF70F00263BE5 /* Sources */,
|
||||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||||
331C80D3294CF70F00263BE5 /* Resources */,
|
331C80D3294CF70F00263BE5 /* Resources */,
|
||||||
@@ -204,11 +234,13 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
2F0E71F402A7EE59DAAFBA4E /* [CP] Check Pods Manifest.lock */,
|
||||||
33CC10E92044A3C60003C045 /* Sources */,
|
33CC10E92044A3C60003C045 /* Sources */,
|
||||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||||
33CC10EB2044A3C60003C045 /* Resources */,
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
|
489AB6A6E2FEEA11824A7C1F /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -291,6 +323,28 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase 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 */ = {
|
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -329,6 +383,45 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
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 */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -380,6 +473,7 @@
|
|||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = CEC5045FB7FBFE960B7B2A22 /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -394,6 +488,7 @@
|
|||||||
};
|
};
|
||||||
331C80DC294CF71000263BE5 /* Release */ = {
|
331C80DC294CF71000263BE5 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 8E43BB6D492C48132D33ABD7 /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -408,6 +503,7 @@
|
|||||||
};
|
};
|
||||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 37DECF423F30FC0794807528 /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
|||||||
@@ -4,4 +4,7 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "91.0.0"
|
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:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -17,6 +25,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.4.1"
|
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:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -105,6 +145,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
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:
|
equatable:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -137,6 +193,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
fl_chart:
|
fl_chart:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -200,6 +264,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
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:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -224,8 +296,24 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
http:
|
gotrue:
|
||||||
dependency: transitive
|
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:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -264,6 +352,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -432,6 +528,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -440,6 +544,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
version: "1.5.2"
|
||||||
|
postgrest:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: postgrest
|
||||||
|
sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.0"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -448,6 +560,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -456,6 +584,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
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:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -533,6 +725,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -549,6 +749,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -589,6 +805,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -661,6 +941,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
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:
|
sdks:
|
||||||
dart: ">=3.10.1 <4.0.0"
|
dart: ">=3.10.1 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.0"
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ dependencies:
|
|||||||
fl_chart: ^1.1.1
|
fl_chart: ^1.1.1
|
||||||
flutter_animate: ^4.5.2
|
flutter_animate: ^4.5.2
|
||||||
google_fonts: ^7.0.1
|
google_fonts: ^7.0.1
|
||||||
|
supabase_flutter: ^2.12.0
|
||||||
|
http: ^1.6.0
|
||||||
|
shared_preferences: ^2.5.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
120
wei_ai_app/supabase/README.md
Normal file
120
wei_ai_app/supabase/README.md
Normal file
@@ -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 并设置为公开访问。
|
||||||
156
wei_ai_app/supabase/migrations/001_create_tables.sql
Normal file
156
wei_ai_app/supabase/migrations/001_create_tables.sql
Normal file
@@ -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 '角色-标签关联表';
|
||||||
135
wei_ai_app/supabase/migrations/002_seed_data.sql
Normal file
135
wei_ai_app/supabase/migrations/002_seed_data.sql
Normal file
@@ -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;
|
||||||
31
wei_ai_app/supabase/migrations/003_create_storage.sql
Normal file
31
wei_ai_app/supabase/migrations/003_create_storage.sql
Normal file
@@ -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');
|
||||||
59
wei_ai_app/supabase/migrations/004_create_llm_config.sql
Normal file
59
wei_ai_app/supabase/migrations/004_create_llm_config.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user