mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: Run Local AI model in AppFlowy (#5655)
* chore: load plugin * chore: sidecar * chore: fix test * chore: clippy * chore: save chat config * chore: arc plugin * chore: add plugins * chore: clippy * chore: test streaming * chore: config chat * chore: stream message * chore: response with local ai * chore: fix compile * chore: config ui * chore: fix load plugin * chore: add docs * chore: update docs * chore: disable local ai * chore: fix compile * chore: clippy
This commit is contained in:
@ -170,7 +170,7 @@ SPEC CHECKSUMS:
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
|
||||
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
||||
image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb
|
||||
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
|
||||
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
|
||||
@ -191,4 +191,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
COCOAPODS: 1.11.3
|
||||
|
@ -0,0 +1,195 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:protobuf/protobuf.dart';
|
||||
|
||||
part 'setting_local_ai_bloc.freezed.dart';
|
||||
|
||||
class SettingsAILocalBloc
|
||||
extends Bloc<SettingsAILocalEvent, SettingsAILocalState> {
|
||||
SettingsAILocalBloc() : super(const SettingsAILocalState()) {
|
||||
on<SettingsAILocalEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
/// Handles incoming events and dispatches them to the appropriate handler.
|
||||
Future<void> _handleEvent(
|
||||
SettingsAILocalEvent event,
|
||||
Emitter<SettingsAILocalState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: _handleStarted,
|
||||
didUpdateAISetting: (settings) async {
|
||||
_handleDidUpdateAISetting(settings, emit);
|
||||
},
|
||||
updateChatBin: (chatBinPath) async {
|
||||
await _handleUpdatePath(
|
||||
filePath: chatBinPath,
|
||||
emit: emit,
|
||||
stateUpdater: () => state.copyWith(
|
||||
chatBinPath: chatBinPath.trim(),
|
||||
chatBinPathError: null,
|
||||
),
|
||||
errorUpdater: (error) => state.copyWith(chatBinPathError: error),
|
||||
);
|
||||
},
|
||||
updateChatModelPath: (chatModelPath) async {
|
||||
await _handleUpdatePath(
|
||||
filePath: chatModelPath,
|
||||
emit: emit,
|
||||
stateUpdater: () => state.copyWith(
|
||||
chatModelPath: chatModelPath.trim(),
|
||||
chatModelPathError: null,
|
||||
),
|
||||
errorUpdater: (error) => state.copyWith(chatModelPathError: error),
|
||||
);
|
||||
},
|
||||
toggleLocalAI: () async {
|
||||
emit(state.copyWith(localAIEnabled: !state.localAIEnabled));
|
||||
},
|
||||
saveSetting: () async {
|
||||
_handleSaveSetting();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles the event to fetch local AI settings when the application starts.
|
||||
Future<void> _handleStarted() async {
|
||||
final result = await ChatEventGetLocalAISetting().send();
|
||||
result.fold(
|
||||
(setting) {
|
||||
if (!isClosed) {
|
||||
add(SettingsAILocalEvent.didUpdateAISetting(setting));
|
||||
}
|
||||
},
|
||||
(err) => Log.error('Failed to get local AI setting: $err'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles the event to update the AI settings in the state.
|
||||
void _handleDidUpdateAISetting(
|
||||
LocalLLMSettingPB settings,
|
||||
Emitter<SettingsAILocalState> emit,
|
||||
) {
|
||||
final newState = state.copyWith(
|
||||
aiSettings: settings,
|
||||
chatBinPath: settings.chatBinPath,
|
||||
chatModelPath: settings.chatModelPath,
|
||||
localAIEnabled: settings.enabled,
|
||||
loadingState: const LoadingState.finish(),
|
||||
);
|
||||
emit(newState.copyWith(saveButtonEnabled: _saveButtonEnabled(newState)));
|
||||
}
|
||||
|
||||
/// Handles updating file paths (both chat binary and chat model paths).
|
||||
Future<void> _handleUpdatePath({
|
||||
required String filePath,
|
||||
required Emitter<SettingsAILocalState> emit,
|
||||
required SettingsAILocalState Function() stateUpdater,
|
||||
required SettingsAILocalState Function(String) errorUpdater,
|
||||
}) async {
|
||||
filePath = filePath.trim();
|
||||
if (filePath.isEmpty) {
|
||||
emit(stateUpdater());
|
||||
return;
|
||||
}
|
||||
|
||||
final validationError = await _validatePath(filePath);
|
||||
if (validationError != null) {
|
||||
emit(errorUpdater(validationError));
|
||||
return;
|
||||
}
|
||||
|
||||
final newState = stateUpdater();
|
||||
emit(newState.copyWith(saveButtonEnabled: _saveButtonEnabled(newState)));
|
||||
}
|
||||
|
||||
/// Validates the provided file path.
|
||||
Future<String?> _validatePath(String filePath) async {
|
||||
if (!isAbsolutePath(filePath)) {
|
||||
return "$filePath must be absolute";
|
||||
}
|
||||
|
||||
if (!await pathExists(filePath)) {
|
||||
return "$filePath does not exist";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Handles saving the updated settings.
|
||||
void _handleSaveSetting() {
|
||||
if (state.aiSettings == null) return;
|
||||
state.aiSettings!.freeze();
|
||||
final newSetting = state.aiSettings!.rebuild((value) {
|
||||
value
|
||||
..chatBinPath = state.chatBinPath ?? value.chatBinPath
|
||||
..chatModelPath = state.chatModelPath ?? value.chatModelPath
|
||||
..enabled = state.localAIEnabled;
|
||||
});
|
||||
|
||||
ChatEventUpdateLocalAISetting(newSetting).send().then((result) {
|
||||
result.fold(
|
||||
(_) {
|
||||
if (!isClosed) {
|
||||
add(SettingsAILocalEvent.didUpdateAISetting(newSetting));
|
||||
}
|
||||
},
|
||||
(err) => Log.error('Failed to update local AI setting: $err'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Determines if the save button should be enabled based on the state.
|
||||
bool _saveButtonEnabled(SettingsAILocalState newState) {
|
||||
return newState.chatBinPathError == null &&
|
||||
newState.chatModelPathError == null &&
|
||||
newState.chatBinPath != null &&
|
||||
newState.chatModelPath != null;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SettingsAILocalEvent with _$SettingsAILocalEvent {
|
||||
const factory SettingsAILocalEvent.started() = _Started;
|
||||
const factory SettingsAILocalEvent.didUpdateAISetting(
|
||||
LocalLLMSettingPB settings,
|
||||
) = _GetAISetting;
|
||||
const factory SettingsAILocalEvent.updateChatBin(String chatBinPath) =
|
||||
_UpdateChatBin;
|
||||
const factory SettingsAILocalEvent.updateChatModelPath(String chatModelPath) =
|
||||
_UpdateChatModelPath;
|
||||
const factory SettingsAILocalEvent.toggleLocalAI() = _EnableLocalAI;
|
||||
const factory SettingsAILocalEvent.saveSetting() = _SaveSetting;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SettingsAILocalState with _$SettingsAILocalState {
|
||||
const factory SettingsAILocalState({
|
||||
LocalLLMSettingPB? aiSettings,
|
||||
String? chatBinPath,
|
||||
String? chatBinPathError,
|
||||
String? chatModelPath,
|
||||
String? chatModelPathError,
|
||||
@Default(false) bool localAIEnabled,
|
||||
@Default(false) bool saveButtonEnabled,
|
||||
@Default(LoadingState.loading()) LoadingState loadingState,
|
||||
}) = _SettingsAILocalState;
|
||||
}
|
||||
|
||||
/// Checks if a given file path is absolute.
|
||||
bool isAbsolutePath(String filePath) {
|
||||
return path.isAbsolute(filePath);
|
||||
}
|
||||
|
||||
/// Checks if a given file or directory path exists.
|
||||
Future<bool> pathExists(String filePath) async {
|
||||
final file = File(filePath);
|
||||
final directory = Directory(filePath);
|
||||
|
||||
return await file.exists() || await directory.exists();
|
||||
}
|
@ -1,3 +1,7 @@
|
||||
import 'package:appflowy/workspace/application/settings/ai/setting_local_ai_bloc.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_input_field.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
@ -40,22 +44,26 @@ class SettingsAIView extends StatelessWidget {
|
||||
create: (_) =>
|
||||
SettingsAIBloc(userProfile)..add(const SettingsAIEvent.started()),
|
||||
child: BlocBuilder<SettingsAIBloc, SettingsAIState>(
|
||||
builder: (_, __) => SettingsBody(
|
||||
title: LocaleKeys.settings_aiPage_title.tr(),
|
||||
description:
|
||||
LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(),
|
||||
children: const [
|
||||
AIModelSeclection(),
|
||||
_AISearchToggle(value: false),
|
||||
],
|
||||
),
|
||||
builder: (context, state) {
|
||||
return SettingsBody(
|
||||
title: LocaleKeys.settings_aiPage_title.tr(),
|
||||
description:
|
||||
LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(),
|
||||
children: const [
|
||||
AIModelSelection(),
|
||||
_AISearchToggle(value: false),
|
||||
// Disable local AI configuration for now. It's not ready for production.
|
||||
// LocalAIConfiguration(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AIModelSeclection extends StatelessWidget {
|
||||
const AIModelSeclection({super.key});
|
||||
class AIModelSelection extends StatelessWidget {
|
||||
const AIModelSelection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -161,3 +169,111 @@ class _AISearchToggle extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LocalAIConfiguration extends StatelessWidget {
|
||||
const LocalAIConfiguration({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
SettingsAILocalBloc()..add(const SettingsAILocalEvent.started()),
|
||||
child: BlocBuilder<SettingsAILocalBloc, SettingsAILocalState>(
|
||||
builder: (context, state) {
|
||||
return state.loadingState.when(
|
||||
loading: () {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
finish: () {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AIConfigurateTextField(
|
||||
title: 'chat bin path',
|
||||
hitText: '',
|
||||
errorText: state.chatBinPathError ?? '',
|
||||
value: state.aiSettings?.chatBinPath ?? '',
|
||||
onChanged: (value) {
|
||||
context.read<SettingsAILocalBloc>().add(
|
||||
SettingsAILocalEvent.updateChatBin(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
const VSpace(16),
|
||||
AIConfigurateTextField(
|
||||
title: 'chat model path',
|
||||
hitText: '',
|
||||
errorText: state.chatModelPathError ?? '',
|
||||
value: state.aiSettings?.chatModelPath ?? '',
|
||||
onChanged: (value) {
|
||||
context.read<SettingsAILocalBloc>().add(
|
||||
SettingsAILocalEvent.updateChatModelPath(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
const VSpace(16),
|
||||
Toggle(
|
||||
value: state.localAIEnabled,
|
||||
onChanged: (_) => context
|
||||
.read<SettingsAILocalBloc>()
|
||||
.add(const SettingsAILocalEvent.toggleLocalAI()),
|
||||
),
|
||||
const VSpace(16),
|
||||
FlowyButton(
|
||||
disable: !state.saveButtonEnabled,
|
||||
text: const FlowyText("save"),
|
||||
onTap: () {
|
||||
context.read<SettingsAILocalBloc>().add(
|
||||
const SettingsAILocalEvent.saveSetting(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AIConfigurateTextField extends StatelessWidget {
|
||||
const AIConfigurateTextField({
|
||||
required this.title,
|
||||
required this.hitText,
|
||||
required this.errorText,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String hitText;
|
||||
final String errorText;
|
||||
final String value;
|
||||
final void Function(String) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText(
|
||||
title,
|
||||
),
|
||||
const VSpace(8),
|
||||
RoundedInputField(
|
||||
hintText: hitText,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
normalBorderColor: Theme.of(context).colorScheme.outline,
|
||||
errorBorderColor: Theme.of(context).colorScheme.error,
|
||||
cursorColor: Theme.of(context).colorScheme.primary,
|
||||
errorText: errorText,
|
||||
initialValue: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user