1141 lines
31 KiB
Dart
1141 lines
31 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:dartx/dartx.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:mime/mime.dart';
|
|
import 'package:ollama_dart/ollama_dart.dart' as llama;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../main.dart';
|
|
import 'chat_database/chat_database.dart';
|
|
import 'clients.dart' as clients;
|
|
import 'haptic.dart';
|
|
import 'model.dart';
|
|
import 'preferences.dart';
|
|
|
|
enum MessageSender { user, assistant }
|
|
|
|
abstract class Message extends ChangeNotifier {
|
|
final String id;
|
|
|
|
final MessageSender sender;
|
|
final DateTime createdAt;
|
|
|
|
Message({required this.sender, DateTime? createdAt})
|
|
: id = const Uuid().v4(),
|
|
createdAt = createdAt ?? DateTime.now();
|
|
|
|
void modify() => notifyListeners();
|
|
|
|
Map<String, dynamic> toJson();
|
|
|
|
@override
|
|
String toString() => toJson().toString();
|
|
|
|
@override
|
|
operator ==(Object other) => other is Message && other.id == id;
|
|
@override
|
|
int get hashCode => id.hashCode;
|
|
|
|
static Message Function(Map<String, dynamic> json)? constructorForType(
|
|
String type,
|
|
) => switch (type) {
|
|
"text" => TextMessage.fromJson,
|
|
"image" => ImageMessage.fromJson,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
class TextMessage extends Message {
|
|
bool _locked = false;
|
|
|
|
String _content;
|
|
String get content => _content;
|
|
|
|
String? _thinking;
|
|
String? get thinking => _thinking;
|
|
|
|
ChatUsageStats? _stats;
|
|
ChatUsageStats? get stats => _stats;
|
|
|
|
bool _includesError = false;
|
|
bool get includesError => _includesError;
|
|
|
|
TextMessage(String content, {required super.sender, super.createdAt})
|
|
: _content = content;
|
|
|
|
@override
|
|
void modify({String? content}) {
|
|
if (_locked) throw StateError("Cannot modify a locked message.");
|
|
if (content != null) _content = content;
|
|
super.modify();
|
|
}
|
|
|
|
@override
|
|
Map<String, dynamic> toJson() => {
|
|
"type": "text",
|
|
"role": sender.name,
|
|
"content": content,
|
|
"thinking": thinking,
|
|
"stats": stats?.toJson(),
|
|
"includesError": includesError,
|
|
"createdAt": createdAt.toUtc().millisecondsSinceEpoch,
|
|
};
|
|
|
|
TextMessage.fromJson(Map<String, dynamic> json)
|
|
: _content = json["content"],
|
|
_thinking = json["thinking"],
|
|
_stats = json["stats"] != null
|
|
? ChatUsageStats.fromJson(json["stats"])
|
|
: null,
|
|
_includesError = json["includesError"] ?? false,
|
|
super(
|
|
sender: MessageSender.values.byName(json["role"]),
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
|
json["createdAt"],
|
|
isUtc: true,
|
|
),
|
|
);
|
|
|
|
factory TextMessage.fromStream(
|
|
Stream<llama.ChatStreamEvent> stream, {
|
|
required MessageSender sender,
|
|
Completer<void>? completer,
|
|
void Function(String content)? onContent,
|
|
Future<void> Function()? saveChat,
|
|
}) => TextMessage("", sender: sender, createdAt: DateTime.now())
|
|
.._contentFromStream(
|
|
stream,
|
|
completer: completer,
|
|
onContent: onContent,
|
|
saveChat: saveChat,
|
|
);
|
|
|
|
Future<void> _contentFromStream(
|
|
Stream<llama.ChatStreamEvent> stream, {
|
|
Completer<void>? completer,
|
|
void Function(String content)? onContent,
|
|
Future<void> Function()? saveChat,
|
|
}) async {
|
|
assert(!_locked, "Cannot modify a locked message.");
|
|
if (_locked) return;
|
|
_locked = true;
|
|
|
|
try {
|
|
StreamSubscription? sub;
|
|
sub = stream.listen((event) {
|
|
if (completer?.isCompleted ?? false) {
|
|
sub?.cancel();
|
|
return;
|
|
}
|
|
|
|
if (event.message?.thinking != null) {
|
|
_thinking = "${_thinking ?? ""}${event.message?.thinking}";
|
|
}
|
|
if (event.done ?? false) {
|
|
_stats = ChatUsageStats.fromChatResponse(event);
|
|
}
|
|
|
|
_content += event.message!.content!;
|
|
chatHaptic();
|
|
|
|
notifyListeners();
|
|
onContent?.call(_content);
|
|
saveChat?.call();
|
|
});
|
|
await sub.asFuture();
|
|
} catch (e, s) {
|
|
if (completer?.isCompleted ?? false) return;
|
|
completer?.completeError(e, s);
|
|
}
|
|
if (completer?.isCompleted ?? false) return;
|
|
_content = _content.trim();
|
|
|
|
Future.delayed(const Duration(milliseconds: 250), heavyHaptic);
|
|
completer?.complete();
|
|
_locked = false;
|
|
}
|
|
}
|
|
|
|
class ImageMessage extends Message {
|
|
Uri _image;
|
|
Uri get image => _image;
|
|
|
|
final String? name;
|
|
|
|
double? _aspectRatio;
|
|
double? get aspectRatio => _aspectRatio;
|
|
|
|
int? _width;
|
|
int? get width => _width;
|
|
|
|
ImageMessage({
|
|
required this._image,
|
|
this.name,
|
|
this._aspectRatio,
|
|
this._width,
|
|
required super.sender,
|
|
super.createdAt,
|
|
});
|
|
|
|
@override
|
|
Map<String, dynamic> toJson() => {
|
|
"type": "image",
|
|
"role": sender.name,
|
|
"image": image.toString(),
|
|
"name": name,
|
|
"aspectRatio": aspectRatio,
|
|
"width": width,
|
|
"createdAt": createdAt.toUtc().millisecondsSinceEpoch,
|
|
};
|
|
|
|
ImageMessage.fromJson(Map<String, dynamic> json)
|
|
: _image = json["image"] != null
|
|
? Uri.parse(json["image"])
|
|
: Uri.parse(
|
|
"data:${lookupMimeType(json["name"])};base64,${json["content"]}",
|
|
),
|
|
name = json["name"],
|
|
_aspectRatio = (json["aspectRatio"] as num?)?.toDouble(),
|
|
_width = json["width"],
|
|
super(
|
|
sender: MessageSender.values.byName(json["role"]),
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
|
json["createdAt"],
|
|
isUtc: true,
|
|
),
|
|
);
|
|
|
|
Future<void> computeImageMeta() async {
|
|
try {
|
|
final data = image.scheme == "ref"
|
|
? await (chatDb.select(chatDb.assets)
|
|
..where((a) => a.id.equals(image.authority)))
|
|
.getSingleOrNull()
|
|
.then((r) => r?.data)
|
|
: base64Decode(image.toString().split(",").last);
|
|
if (data == null) return;
|
|
|
|
final decodedImage = img.decodeImage(data);
|
|
if (decodedImage == null) return;
|
|
|
|
_aspectRatio = decodedImage.width / decodedImage.height;
|
|
_width = decodedImage.width;
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<ImageProvider> get imageProvider async {
|
|
assert(
|
|
image.scheme == "ref",
|
|
"Only images with a 'ref' scheme should be used; please await for migration before using images.",
|
|
);
|
|
|
|
if (image.scheme == "ref") {
|
|
final img = await (chatDb.select(
|
|
chatDb.assets,
|
|
)..where((img) => img.id.equals(image.authority))).getSingleOrNull();
|
|
if (img != null) return MemoryImage(img.data);
|
|
} else if (image.scheme == "data") {
|
|
return MemoryImage(base64Decode(image.toString().split(",").last));
|
|
}
|
|
return NetworkImage(image.toString());
|
|
}
|
|
}
|
|
|
|
class ChatUsageStats {
|
|
final Duration totalDuration;
|
|
final Duration? loadDuration;
|
|
final int promptEvalCount;
|
|
final Duration? promptEvalDuration;
|
|
final int evalCount;
|
|
final Duration? evalDuration;
|
|
|
|
ChatUsageStats._({
|
|
required this.totalDuration,
|
|
required this.loadDuration,
|
|
required this.promptEvalCount,
|
|
required this.promptEvalDuration,
|
|
required this.evalCount,
|
|
required this.evalDuration,
|
|
});
|
|
|
|
static ChatUsageStats? fromChatResponse(llama.ChatStreamEvent response) {
|
|
if (response.totalDuration == null ||
|
|
response.promptEvalCount == null ||
|
|
response.evalCount == null) {
|
|
return null;
|
|
}
|
|
|
|
return ChatUsageStats._(
|
|
totalDuration: Duration(microseconds: response.totalDuration! ~/ 1000),
|
|
loadDuration: response.loadDuration != null
|
|
? Duration(microseconds: response.loadDuration! ~/ 1000)
|
|
: null,
|
|
promptEvalCount: response.promptEvalCount!,
|
|
promptEvalDuration: response.promptEvalDuration != null
|
|
? Duration(microseconds: response.promptEvalDuration! ~/ 1000)
|
|
: null,
|
|
evalCount: response.evalCount!,
|
|
evalDuration: response.evalDuration != null
|
|
? Duration(microseconds: response.evalDuration! ~/ 1000)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
"totalDuration": totalDuration.inMicroseconds,
|
|
"loadDuration": loadDuration?.inMicroseconds,
|
|
"promptEvalCount": promptEvalCount,
|
|
"promptEvalDuration": promptEvalDuration?.inMicroseconds,
|
|
"evalCount": evalCount,
|
|
"evalDuration": evalDuration?.inMicroseconds,
|
|
};
|
|
|
|
factory ChatUsageStats.fromJson(Map<String, dynamic> json) =>
|
|
ChatUsageStats._(
|
|
totalDuration: Duration(microseconds: json["totalDuration"]),
|
|
loadDuration: json["loadDuration"] != null
|
|
? Duration(microseconds: json["loadDuration"])
|
|
: null,
|
|
promptEvalCount: json["promptEvalCount"],
|
|
promptEvalDuration: json["loadDuration"] != null
|
|
? Duration(microseconds: json["promptEvalDuration"])
|
|
: null,
|
|
evalCount: json["evalCount"],
|
|
evalDuration: json["loadDuration"] != null
|
|
? Duration(microseconds: json["evalDuration"])
|
|
: null,
|
|
);
|
|
}
|
|
|
|
class Chat extends ChangeNotifier {
|
|
Completer<void> completer = Completer<void>()..complete();
|
|
|
|
bool get alive => ChatManager.instance.chats.contains(this);
|
|
bool get active => alive && ChatManager.instance.currentChatId == id;
|
|
|
|
final String id;
|
|
final DateTime createdAt;
|
|
|
|
String? _modelName;
|
|
String? get modelName => _modelName;
|
|
set modelName(String? name) {
|
|
assert(alive, "Chat must be alive to be modified.");
|
|
if (!alive) return;
|
|
|
|
_modelName = name;
|
|
if (ChatManager.instance.currentChatId == id) {
|
|
ModelManager.instance.currentModelName = name;
|
|
}
|
|
notifyListeners();
|
|
saveChat();
|
|
}
|
|
|
|
Model? get model => ModelManager.instance.models.firstOrNullWhere(
|
|
(m) => m.name == _modelName,
|
|
);
|
|
set model(Model? model) => modelName = model?.name;
|
|
|
|
String _title;
|
|
String get title => _title;
|
|
set title(String title) {
|
|
assert(alive, "Chat must be alive to be modified.");
|
|
if (!alive) return;
|
|
|
|
_title = title;
|
|
notifyListeners();
|
|
saveChat();
|
|
}
|
|
|
|
final List<Message> _messages;
|
|
List<Message> get messages => List.unmodifiable(_messages);
|
|
|
|
final String? system;
|
|
|
|
Chat._({
|
|
String? id,
|
|
required String? modelName,
|
|
required this._title,
|
|
required this.createdAt,
|
|
List<Message>? messages,
|
|
String? system,
|
|
}) : id = id ?? const Uuid().v4(),
|
|
_modelName = modelName ?? ModelManager.instance.currentModelName,
|
|
_messages = messages ?? [],
|
|
system = system ?? Preferences.instance.system {
|
|
addListener(ChatManager.instance.notifyListeners);
|
|
for (var m in _messages) {
|
|
m.addListener(notifyListeners);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (var message in _messages) {
|
|
message.removeListener(notifyListeners);
|
|
}
|
|
if (completer.isCompleted == false) completer.complete();
|
|
if (ChatManager.instance.currentChatId == id) {
|
|
ChatManager.instance.currentChatId = null;
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
List<llama.ChatMessage> toApi() {
|
|
final messages = <llama.ChatMessage>[];
|
|
final images = <ImageMessage>[];
|
|
|
|
final systemMessage = system;
|
|
if (systemMessage != null) {
|
|
messages.add(
|
|
llama.ChatMessage(
|
|
role: llama.MessageRole.system,
|
|
content: systemMessage,
|
|
),
|
|
);
|
|
}
|
|
|
|
for (var message in _messages) {
|
|
switch (message) {
|
|
case final TextMessage message:
|
|
messages.add(
|
|
llama.ChatMessage(
|
|
role: message.sender == MessageSender.user
|
|
? llama.MessageRole.user
|
|
: llama.MessageRole.assistant,
|
|
content: message.content,
|
|
images: images.map((e) => e.image.toString()).toList(),
|
|
),
|
|
);
|
|
images.clear();
|
|
case final ImageMessage message
|
|
when message.sender == MessageSender.user:
|
|
images.add(message);
|
|
}
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
"id": id,
|
|
"model": model?.name,
|
|
"createdAt": createdAt.toUtc().millisecondsSinceEpoch,
|
|
"title": title,
|
|
"messages": _messages.map((e) => e.toJson()).toList(),
|
|
"system": system,
|
|
};
|
|
|
|
Future<void> generateTitle({
|
|
required BuildContext context,
|
|
bool? think = false,
|
|
}) async {
|
|
assert(alive, "Chat must be alive to be modified.");
|
|
assert(model != null, "Chat model must be set to generate a title.");
|
|
if (!alive || model == null) return;
|
|
|
|
if (_messages.isEmpty ||
|
|
model == null ||
|
|
!Preferences.instance.generateTitles) {
|
|
_title = AppLocalizations.of(context).newChatTitle;
|
|
return;
|
|
}
|
|
|
|
final effectiveThink =
|
|
(think ?? false) &&
|
|
model!.capabilities.contains(ModelCapability.thinking);
|
|
|
|
final content = jsonEncode(
|
|
(toJson()["messages"] as List<Map<String, dynamic>>)
|
|
.map(
|
|
(e) => e
|
|
..removeWhere(
|
|
(k, _) => !["type", "role", "content", "name"].contains(k),
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
final request = llama.ChatRequest(
|
|
model: modelName!,
|
|
messages: [
|
|
const llama.ChatMessage(
|
|
role: llama.MessageRole.system,
|
|
content:
|
|
"Generate a two to five word title for the conversation provided by the user. "
|
|
"Focus on the main subject or topic. If a specific object, person, or concept is central to the discussion, include it in the title. "
|
|
"Do not focus on the assistant; never use the word 'assistant' or imply it is the subject. "
|
|
"Use a factual, neutral tone. Prefer nouns and adjectives over verbs. Do not use dramatic, vague, or belittling words such as 'simple', 'easy', 'big', or 'interesting'. "
|
|
"Use title case. Do not use markdown, emojis, symbols, or generic terms like 'assistance', 'help', 'session', or 'conversation'.\n\n"
|
|
"---\n\nExamples (bad -> good):\n\n~~User Introduces Themselves~~ -> User Introduction\n~~User Asks for Help with a Problem~~ -> Problem Troubleshooting\n~~User has a _**big**_ Problem~~ -> Issue Diagnosis\n~~Simple Python Question~~ -> Python Syntax Help",
|
|
),
|
|
llama.ChatMessage(
|
|
role: llama.MessageRole.user,
|
|
content: "```json\n$content\n```",
|
|
),
|
|
],
|
|
keepAlive: llama.KeepAlive.number(Preferences.instance.keepAlive),
|
|
think: llama.ThinkValue.enabled(effectiveThink),
|
|
);
|
|
|
|
llama.ChatResponse generated;
|
|
try {
|
|
generated = await clients.ollamaClient.chat
|
|
.create(request: request)
|
|
.timeout(TimeoutMultiplier.long);
|
|
} catch (e, s) {
|
|
if (alive) Error.throwWithStackTrace(e, s);
|
|
return;
|
|
}
|
|
if (!alive) return;
|
|
|
|
var newTitle = generated.message!.content!;
|
|
newTitle = newTitle.replaceAll("\n", " ");
|
|
|
|
for (var term in [
|
|
'"',
|
|
"'",
|
|
"*",
|
|
"_",
|
|
".",
|
|
",",
|
|
"!",
|
|
"?",
|
|
":",
|
|
";",
|
|
"(",
|
|
")",
|
|
"[",
|
|
"]",
|
|
"{",
|
|
"}",
|
|
"<",
|
|
">",
|
|
]) {
|
|
newTitle = newTitle.replaceAll(term, "");
|
|
}
|
|
|
|
while (newTitle.contains(" " * 2)) {
|
|
newTitle = newTitle.replaceAll(" " * 2, " " * 1);
|
|
}
|
|
|
|
title = newTitle.trim();
|
|
}
|
|
|
|
void append(Message message) {
|
|
assert(alive, "Chat must be alive to be modified.");
|
|
if (!alive) return;
|
|
|
|
_messages.add(message..addListener(notifyListeners));
|
|
notifyListeners();
|
|
saveChat();
|
|
}
|
|
|
|
Future<void> send(
|
|
Message message, {
|
|
bool awaitCompletion = true,
|
|
bool? think,
|
|
void Function(String content)? onContent,
|
|
}) async {
|
|
assert(alive, "Chat must be alive to be modified.");
|
|
if (!alive) return;
|
|
|
|
assert(model != null, "Chat model must be set to send messages.");
|
|
if (model == null) return;
|
|
|
|
assert(
|
|
completer.isCompleted,
|
|
"Cannot send a message while another message is being sent.",
|
|
);
|
|
if (!completer.isCompleted) return;
|
|
|
|
_messages.add(message..addListener(notifyListeners));
|
|
|
|
final finalThink =
|
|
(think ?? Preferences.instance.thinking) &&
|
|
model!.capabilities.contains(ModelCapability.thinking);
|
|
|
|
if (message is TextMessage && message.sender == MessageSender.user) {
|
|
completer = Completer<void>();
|
|
final message = TextMessage.fromStream(
|
|
clients.ollamaClient.chat.createStream(
|
|
request: llama.ChatRequest(
|
|
model: model!.name,
|
|
messages: toApi(),
|
|
stream: true,
|
|
keepAlive: llama.KeepAlive.number(Preferences.instance.keepAlive),
|
|
think: llama.ThinkValue.enabled(finalThink),
|
|
),
|
|
),
|
|
sender: MessageSender.assistant,
|
|
completer: completer,
|
|
onContent: onContent,
|
|
saveChat: () async {
|
|
if (alive) await saveChat();
|
|
},
|
|
)..addListener(notifyListeners);
|
|
_messages.add(message);
|
|
|
|
final future = completer.future.catchError((e, s) {
|
|
message
|
|
.._locked = false
|
|
.._includesError = true
|
|
..modify();
|
|
Error.throwWithStackTrace(e, s);
|
|
});
|
|
if (awaitCompletion) await future;
|
|
} else if (message is ImageMessage) {
|
|
await message.computeImageMeta();
|
|
}
|
|
|
|
notifyListeners();
|
|
if (alive) await saveChat();
|
|
}
|
|
|
|
void delete(Message message) {
|
|
assert(alive, "Chat must be alive to be modified.");
|
|
if (!alive) return;
|
|
|
|
_messages.remove(message);
|
|
message.removeListener(notifyListeners);
|
|
notifyListeners();
|
|
saveChat();
|
|
}
|
|
|
|
Future<void> saveChat() async {
|
|
assert(alive, "Chat must be alive to be modified.");
|
|
if (!alive) return;
|
|
|
|
await chatDb
|
|
.into(chatDb.chats)
|
|
.insertOnConflictUpdate(
|
|
ChatsCompanion(id: Value(id), json: Value(jsonEncode(toJson()))),
|
|
);
|
|
}
|
|
|
|
Future<bool> _migrateImageStorageFormat() async {
|
|
var didMigrate = false;
|
|
for (var message in _messages.whereType<ImageMessage>()) {
|
|
if (message.image.scheme != "ref") {
|
|
final id = const Uuid().v4();
|
|
final data = base64Decode(message.image.toString().split(",").last);
|
|
await chatDb
|
|
.into(chatDb.assets)
|
|
.insert(
|
|
AssetsCompanion(
|
|
id: Value(id),
|
|
chatId: Value(this.id),
|
|
data: Value(data),
|
|
),
|
|
);
|
|
|
|
double? aspectRatio;
|
|
int? width;
|
|
if (message.aspectRatio == null || message.width == null) {
|
|
try {
|
|
final decodedImage = img.decodeImage(data);
|
|
if (decodedImage != null) {
|
|
aspectRatio = decodedImage.width / decodedImage.height;
|
|
width = decodedImage.width;
|
|
}
|
|
} catch (_) {}
|
|
} else {
|
|
aspectRatio = message.aspectRatio;
|
|
width = message.width;
|
|
}
|
|
|
|
message
|
|
.._image = Uri.parse("ref://$id")
|
|
.._aspectRatio = aspectRatio
|
|
.._width = width;
|
|
didMigrate = true;
|
|
}
|
|
}
|
|
return didMigrate;
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) => other is Chat && other.id == id;
|
|
@override
|
|
int get hashCode => id.hashCode;
|
|
}
|
|
|
|
class ChatManager extends ChangeNotifier {
|
|
static final ChatManager _instance = ChatManager._();
|
|
static ChatManager get instance => _instance;
|
|
|
|
String? _currentChatId;
|
|
String? get currentChatId => _currentChatId;
|
|
set currentChatId(String? id) {
|
|
final chat = _chats.where((e) => e.id == id).firstOrNull;
|
|
if (chat == null && id != null) {
|
|
throw ArgumentError.value(id, "id", "Chat with this ID does not exist.");
|
|
}
|
|
|
|
_currentChatId = id;
|
|
if (id != null) {
|
|
if (chat!.model != null) {
|
|
ModelManager.instance.currentModelName = chat.model!.name;
|
|
}
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Chat? get currentChat => _currentChatId == null
|
|
? null
|
|
: _chats.singleWhere((e) => e.id == _currentChatId);
|
|
set currentChat(Chat? chat) => currentChatId = chat?.id;
|
|
|
|
final Set<Chat> _chats = {};
|
|
Set<Chat> get chats => Set.unmodifiable(_chats);
|
|
|
|
ChatManager._();
|
|
|
|
Future<void> loadChats() async {
|
|
DateTime getDateTimeFromMilliseconds(int? milliseconds) {
|
|
if (milliseconds == null) return DateTime.now();
|
|
return DateTime.fromMillisecondsSinceEpoch(milliseconds, isUtc: true);
|
|
}
|
|
|
|
var stored = <String>[];
|
|
|
|
final oldPrefs = await SharedPreferences.getInstance();
|
|
final oldStored = oldPrefs.getStringList("chats") ?? [];
|
|
if (oldStored.isNotEmpty) {
|
|
stored = oldStored;
|
|
await oldPrefs.remove("chats");
|
|
await prefs!.remove("chats");
|
|
}
|
|
|
|
if (stored.isEmpty) {
|
|
stored = await chatDb
|
|
.select(chatDb.chats)
|
|
.get()
|
|
.then((rows) => rows.map((r) => r.json).toList());
|
|
}
|
|
|
|
_chats.clear();
|
|
for (var chatJson in stored) {
|
|
try {
|
|
final chatData = jsonDecode(chatJson);
|
|
|
|
final messages = <Message>[];
|
|
String? system;
|
|
|
|
final data = chatData["messages"] is String
|
|
? (jsonDecode(chatData["messages"]) as List)
|
|
: chatData["messages"];
|
|
|
|
for (var message in data) {
|
|
if (message["role"] == "system") {
|
|
system = message["content"];
|
|
continue;
|
|
}
|
|
|
|
final constructor = Message.constructorForType(
|
|
message["type"] ?? "text",
|
|
);
|
|
if (constructor == null) continue;
|
|
messages.add(constructor.call(message));
|
|
}
|
|
|
|
_chats.add(
|
|
Chat._(
|
|
id: chatData["uuid"] ?? chatData["id"],
|
|
modelName: chatData["model"],
|
|
createdAt: getDateTimeFromMilliseconds(chatData["createdAt"]),
|
|
title: chatData["title"],
|
|
messages: messages,
|
|
system: system,
|
|
)..addListener(notifyListeners),
|
|
);
|
|
} catch (_) {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
if (oldStored.isNotEmpty) await saveChats();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> saveChats() async {
|
|
for (var chat in _chats) {
|
|
await chatDb
|
|
.into(chatDb.chats)
|
|
.insertOnConflictUpdate(
|
|
ChatsCompanion(
|
|
id: Value(chat.id),
|
|
json: Value(jsonEncode(chat.toJson())),
|
|
),
|
|
);
|
|
if (await chat._migrateImageStorageFormat()) await chat.saveChat();
|
|
}
|
|
}
|
|
|
|
Chat createChat({
|
|
required BuildContext? context,
|
|
Model? model,
|
|
String? title,
|
|
String? system,
|
|
}) {
|
|
final chat = Chat._(
|
|
modelName: model?.name,
|
|
createdAt: DateTime.now(),
|
|
title:
|
|
title ??
|
|
((context != null)
|
|
? AppLocalizations.of(context).newChatTitle
|
|
: "Unnamed Chat"),
|
|
system: system ?? Preferences.instance.system,
|
|
);
|
|
_chats.add(chat);
|
|
_currentChatId = chat.id;
|
|
|
|
notifyListeners();
|
|
chat.saveChat();
|
|
|
|
return chat;
|
|
}
|
|
|
|
void deleteChat(Chat chat) {
|
|
_chats.remove(chat);
|
|
chat.removeListener(notifyListeners);
|
|
for (var message in chat.messages) {
|
|
message.removeListener(chat.notifyListeners);
|
|
}
|
|
if (chat.completer.isCompleted == false) chat.completer.complete();
|
|
|
|
if (_currentChatId == chat.id) _currentChatId = null;
|
|
|
|
notifyListeners();
|
|
(chatDb.delete(chatDb.chats)..where((c) => c.id.equals(chat.id))).go();
|
|
}
|
|
}
|
|
|
|
// MARK: Delete Chat Dialog
|
|
|
|
Future<bool> showDeleteChatDialog(
|
|
BuildContext context, {
|
|
Chat? chat,
|
|
FutureOr<void> Function()? onDelete,
|
|
}) {
|
|
chat ??= ChatManager.instance.currentChat;
|
|
final completer = Completer<bool>();
|
|
if (Preferences.instance.askBeforeDeletion) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
return DeleteChatDialog(
|
|
chat: chat!,
|
|
onDelete: onDelete,
|
|
completer: completer,
|
|
);
|
|
},
|
|
);
|
|
} else {
|
|
ChatManager.instance.deleteChat(chat!);
|
|
onDelete?.call();
|
|
completer.complete(true);
|
|
}
|
|
return completer.future;
|
|
}
|
|
|
|
class DeleteChatDialog extends StatelessWidget {
|
|
final Chat chat;
|
|
final FutureOr<void> Function()? onDelete;
|
|
final Completer<bool> completer;
|
|
|
|
const DeleteChatDialog({
|
|
super.key,
|
|
required this.chat,
|
|
required this.onDelete,
|
|
required this.completer,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
onPopInvokedWithResult: (_, _) {
|
|
if (!completer.isCompleted) completer.complete(false);
|
|
},
|
|
child: AlertDialog(
|
|
title: Text(AppLocalizations.of(context).deleteDialogTitle),
|
|
content: Text(AppLocalizations.of(context).deleteDialogDescription),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(AppLocalizations.of(context).deleteDialogCancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
ChatManager.instance.deleteChat(chat);
|
|
completer.complete(true);
|
|
onDelete?.call();
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Text(AppLocalizations.of(context).deleteDialogDelete),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// MARK: Chat Text Widget
|
|
|
|
class _AnimatedWord {
|
|
final String text;
|
|
final WidgetSpan? widgetSpan;
|
|
TextStyle? style;
|
|
AnimationController? controller;
|
|
|
|
bool get isWidget => widgetSpan != null;
|
|
|
|
_AnimatedWord(this.text, this.style, [this.controller]) : widgetSpan = null;
|
|
_AnimatedWord.forWidget(this.widgetSpan)
|
|
: text = '',
|
|
style = null,
|
|
controller = null;
|
|
}
|
|
|
|
class ChatText extends StatefulWidget {
|
|
final InlineSpan content;
|
|
final Duration? flyInDuration;
|
|
final Duration? wordDelay;
|
|
final Widget? placeholder;
|
|
|
|
const ChatText(
|
|
this.content, {
|
|
super.key,
|
|
this.flyInDuration,
|
|
this.wordDelay,
|
|
this.placeholder,
|
|
});
|
|
|
|
@override
|
|
State<ChatText> createState() => _ChatTextState();
|
|
}
|
|
|
|
class _ChatTextState extends State<ChatText> with TickerProviderStateMixin {
|
|
final List<_AnimatedWord> _words = [];
|
|
final List<_AnimatedWord> _queue = [];
|
|
Timer? _delayTimer;
|
|
DateTime? _lastWordShownAt;
|
|
|
|
List<String> _splitWords(String text) {
|
|
if (text.isEmpty) return const [];
|
|
return text.split(RegExp(r'(?=\s)')).where((s) => s.isNotEmpty).toList();
|
|
}
|
|
|
|
List<_AnimatedWord> _flattenAll(InlineSpan span, [TextStyle? parent]) {
|
|
final result = <_AnimatedWord>[];
|
|
if (span is TextSpan) {
|
|
final eff = (parent != null && span.style != null)
|
|
? parent.merge(span.style!)
|
|
: (span.style ?? parent);
|
|
if (span.text?.isNotEmpty ?? false) {
|
|
result.add(_AnimatedWord(span.text!, eff));
|
|
}
|
|
for (var child in span.children ?? const <InlineSpan>[]) {
|
|
result.addAll(_flattenAll(child, eff));
|
|
}
|
|
} else if (span is WidgetSpan) {
|
|
result.add(_AnimatedWord.forWidget(span));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
for (var entry in _flattenAll(widget.content)) {
|
|
if (entry.isWidget) {
|
|
_words.add(entry);
|
|
} else {
|
|
for (var w in _splitWords(entry.text)) {
|
|
_words.add(_AnimatedWord(w, entry.style));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(ChatText old) {
|
|
super.didUpdateWidget(old);
|
|
if (widget.content != old.content) _processUpdate();
|
|
}
|
|
|
|
void _enqueue(_AnimatedWord word) {
|
|
if (widget.wordDelay == null) {
|
|
word.controller?.forward();
|
|
return;
|
|
}
|
|
_queue.add(word);
|
|
_drainQueue();
|
|
}
|
|
|
|
void _drainQueue() {
|
|
if ((_delayTimer?.isActive ?? false) || _queue.isEmpty) return;
|
|
final remaining =
|
|
widget.wordDelay! -
|
|
(_lastWordShownAt != null
|
|
? DateTime.now().difference(_lastWordShownAt!)
|
|
: widget.wordDelay!);
|
|
if (remaining <= Duration.zero) {
|
|
_lastWordShownAt = DateTime.now();
|
|
_queue.removeAt(0).controller?.forward();
|
|
if (_queue.isNotEmpty) _drainQueue();
|
|
} else {
|
|
_delayTimer = Timer(remaining, _drainQueue);
|
|
}
|
|
}
|
|
|
|
void _cancelQueue() {
|
|
_delayTimer?.cancel();
|
|
_delayTimer = null;
|
|
_lastWordShownAt = null;
|
|
_queue.clear();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_cancelQueue();
|
|
for (var w in _words) {
|
|
w.controller?.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _processUpdate() {
|
|
final newEntries = _flattenAll(widget.content);
|
|
final newFullText = newEntries
|
|
.where((e) => !e.isWidget)
|
|
.map((e) => e.text)
|
|
.join();
|
|
final oldFullText = _words
|
|
.where((w) => !w.isWidget)
|
|
.map((w) => w.text)
|
|
.join();
|
|
|
|
if (newFullText == oldFullText) {
|
|
_syncStyles(newEntries);
|
|
setState(() {});
|
|
return;
|
|
}
|
|
|
|
if (newFullText.startsWith(oldFullText)) {
|
|
_animateAppended(newEntries, oldFullText.length);
|
|
} else {
|
|
_reset(newEntries);
|
|
}
|
|
}
|
|
|
|
void _syncStyles(List<_AnimatedWord> entries) {
|
|
final flat = <_AnimatedWord>[];
|
|
for (var entry in entries) {
|
|
if (entry.isWidget) continue;
|
|
for (var w in _splitWords(entry.text)) {
|
|
flat.add(_AnimatedWord(w, entry.style));
|
|
}
|
|
}
|
|
final textWords = _words.where((w) => !w.isWidget).toList();
|
|
for (var i = 0; i < textWords.length && i < flat.length; i++) {
|
|
textWords[i].style = flat[i].style;
|
|
}
|
|
}
|
|
|
|
void _animateAppended(List<_AnimatedWord> newEntries, int oldLength) {
|
|
var seen = 0;
|
|
for (var leaf in newEntries) {
|
|
if (leaf.isWidget) {
|
|
if (seen >= oldLength) _words.add(leaf);
|
|
continue;
|
|
}
|
|
final leafEnd = seen + leaf.text.length;
|
|
if (leafEnd <= oldLength) {
|
|
seen = leafEnd;
|
|
continue;
|
|
}
|
|
final start = (oldLength - seen).clamp(0, leaf.text.length);
|
|
final newText = leaf.text.substring(start);
|
|
for (var word in _splitWords(newText)) {
|
|
final ctrl = AnimationController(
|
|
value: 0.0,
|
|
vsync: this,
|
|
duration: widget.flyInDuration ?? Durations.medium1,
|
|
);
|
|
final entry = _AnimatedWord(word, leaf.style, ctrl);
|
|
ctrl
|
|
..addListener(() {
|
|
if (mounted) setState(() {});
|
|
})
|
|
..addStatusListener((status) {
|
|
if (status == AnimationStatus.completed) {
|
|
entry.controller?.dispose();
|
|
entry.controller = null;
|
|
}
|
|
});
|
|
_words.add(entry);
|
|
_enqueue(entry);
|
|
}
|
|
seen = leafEnd;
|
|
}
|
|
}
|
|
|
|
void _reset(List<_AnimatedWord> entries) {
|
|
_cancelQueue();
|
|
for (var w in _words) {
|
|
w.controller?.dispose();
|
|
}
|
|
_words.clear();
|
|
for (var entry in entries) {
|
|
if (entry.isWidget) {
|
|
_words.add(entry);
|
|
} else {
|
|
for (var w in _splitWords(entry.text)) {
|
|
_words.add(_AnimatedWord(w, entry.style));
|
|
}
|
|
}
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isEmpty = _words.isEmpty;
|
|
if (isEmpty && widget.placeholder != null) return widget.placeholder!;
|
|
|
|
final defaultColor =
|
|
Theme.of(context).textTheme.bodyMedium?.color ?? Colors.black;
|
|
|
|
return AnimatedSize(
|
|
alignment: Alignment.topLeft,
|
|
duration: Durations.short2,
|
|
child: Text.rich(
|
|
TextSpan(
|
|
children: _words
|
|
.where((w) => w.controller?.status != AnimationStatus.dismissed)
|
|
.map((word) {
|
|
final alpha = word.controller?.value;
|
|
if (alpha == null) {
|
|
return TextSpan(text: word.text, style: word.style);
|
|
}
|
|
|
|
final baseColor = word.style?.color ?? defaultColor;
|
|
return TextSpan(
|
|
text: word.text,
|
|
style: (word.style ?? const TextStyle()).copyWith(
|
|
color: baseColor.withValues(alpha: alpha),
|
|
),
|
|
);
|
|
})
|
|
.toList(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|