chore: chat with pdf ui (#5811)

* chore: chat with pdf ui

* chore: only enable local ai on macos

* chore: add todo

* chore: adjust UI

* chore: clippy
This commit is contained in:
Nathan.fooo 2024-07-26 07:58:54 +08:00 committed by GitHub
parent d1af172fb7
commit a2e211555e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 225 additions and 149 deletions

View File

@ -17,7 +17,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
required this.questionId,
}) : super(ChatAIMessageState.initial(message)) {
if (state.stream != null) {
_subscription = state.stream!.listen(
state.stream!.listen(
onData: (text) {
if (!isClosed) {
add(ChatAIMessageEvent.updateText(text));
@ -108,13 +108,6 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
);
}
@override
Future<void> close() {
_subscription?.cancel();
return super.close();
}
StreamSubscription<String>? _subscription;
final String chatId;
final Int64? questionId;
}

View File

@ -586,7 +586,7 @@ class AnswerStream {
_port.close();
}
StreamSubscription<String> listen({
void listen({
void Function(String text)? onData,
void Function()? onStart,
void Function()? onEnd,
@ -602,7 +602,5 @@ class AnswerStream {
if (_onStart != null) {
_onStart!();
}
return _subscription;
}
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@ -13,6 +15,11 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
}) : listener = LocalLLMListener(),
super(const ChatFileState()) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(ChatFileEvent.updatePluginState(pluginState));
}
},
chatStateCallback: (chatState) {
if (!isClosed) {
add(ChatFileEvent.updateChatState(chatState));
@ -38,18 +45,55 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
},
);
},
newFile: (String filePath) {
newFile: (String filePath, String fileName) async {
emit(
state.copyWith(
indexFileIndicator: IndexFileIndicator.indexing(fileName),
),
);
final payload = ChatFilePB(filePath: filePath, chatId: chatId);
ChatEventChatWithFile(payload).send();
unawaited(
ChatEventChatWithFile(payload).send().then((result) {
if (!isClosed) {
result.fold((_) {
add(
ChatFileEvent.updateIndexFile(
IndexFileIndicator.finish(fileName),
),
);
}, (err) {
add(
ChatFileEvent.updateIndexFile(
IndexFileIndicator.error(err.msg),
),
);
});
}
}),
);
},
updateChatState: (LocalAIChatPB chatState) {
// Only user enable chat with file and the plugin is already running
final supportChatWithFile = chatState.fileEnabled &&
chatState.pluginState.state == RunningStatePB.Running;
emit(
state.copyWith(supportChatWithFile: supportChatWithFile),
state.copyWith(
supportChatWithFile: supportChatWithFile,
chatState: chatState,
),
);
},
updateIndexFile: (IndexFileIndicator indicator) {
emit(
state.copyWith(indexFileIndicator: indicator),
);
},
updatePluginState: (LocalAIPluginStatePB chatState) {
final fileEnabled = state.chatState?.fileEnabled ?? false;
final supportChatWithFile =
fileEnabled && chatState.state == RunningStatePB.Running;
emit(state.copyWith(supportChatWithFile: supportChatWithFile));
},
);
},
);
@ -67,20 +111,29 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
@freezed
class ChatFileEvent with _$ChatFileEvent {
const factory ChatFileEvent.initial() = Initial;
const factory ChatFileEvent.newFile(String filePath) = _NewFile;
const factory ChatFileEvent.newFile(String filePath, String fileName) =
_NewFile;
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
_UpdateChatState;
const factory ChatFileEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
const factory ChatFileEvent.updateIndexFile(IndexFileIndicator indicator) =
_UpdateIndexFile;
}
@freezed
class ChatFileState with _$ChatFileState {
const factory ChatFileState({
@Default(false) bool supportChatWithFile,
IndexFileIndicator? indexFileIndicator,
LocalAIChatPB? chatState,
}) = _ChatFileState;
}
@freezed
class LocalAIChatFileIndicator with _$LocalAIChatFileIndicator {
const factory LocalAIChatFileIndicator.ready(bool isEnabled) = _Ready;
const factory LocalAIChatFileIndicator.loading() = _Loading;
class IndexFileIndicator with _$IndexFileIndicator {
const factory IndexFileIndicator.finish(String fileName) = _Finish;
const factory IndexFileIndicator.indexing(String fileName) = _Indexing;
const factory IndexFileIndicator.error(String error) = _Error;
}

View File

@ -1,5 +1,7 @@
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -71,7 +73,8 @@ class AIChatPage extends StatelessWidget {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => ChatFileBloc(chatId: view.id.toString()),
create: (_) => ChatFileBloc(chatId: view.id.toString())
..add(const ChatFileEvent.initial()),
),
BlocProvider(
create: (_) => ChatBloc(
@ -81,28 +84,40 @@ class AIChatPage extends StatelessWidget {
),
BlocProvider(create: (_) => ChatInputBloc()),
],
child: BlocBuilder<ChatFileBloc, ChatFileState>(
builder: (context, state) {
Widget child = _ChatContentPage(
view: view,
userProfile: userProfile,
);
// If the chat supports file upload, wrap the chat content with a drop target
if (state.supportChatWithFile) {
child = DropTarget(
child: BlocListener<ChatFileBloc, ChatFileState>(
listenWhen: (previous, current) =>
previous.indexFileIndicator != current.indexFileIndicator,
listener: (context, state) {
_handleIndexIndicator(state.indexFileIndicator, context);
},
child: BlocBuilder<ChatFileBloc, ChatFileState>(
builder: (context, state) {
return DropTarget(
onDragDone: (DropDoneDetails detail) async {
for (final file in detail.files) {
context
.read<ChatFileBloc>()
.add(ChatFileEvent.newFile(file.path));
if (state.supportChatWithFile) {
await showConfirmDialog(
context: context,
style: ConfirmPopupStyle.cancelAndOk,
title: LocaleKeys.chat_chatWithFilePrompt.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () {
for (final file in detail.files) {
context
.read<ChatFileBloc>()
.add(ChatFileEvent.newFile(file.path, file.name));
}
},
description: '',
);
}
},
child: child,
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
);
}
return child;
},
},
),
),
);
}
@ -114,6 +129,35 @@ class AIChatPage extends StatelessWidget {
),
);
}
void _handleIndexIndicator(
IndexFileIndicator? indicator,
BuildContext context,
) {
if (indicator != null) {
indicator.when(
finish: (fileName) {
showSnackBarMessage(
context,
LocaleKeys.chat_indexFileSuccess.tr(args: [fileName]),
);
},
indexing: (fileName) {
showSnackBarMessage(
context,
LocaleKeys.chat_indexingFile.tr(args: [fileName]),
duration: const Duration(seconds: 2),
);
},
error: (err) {
showSnackBarMessage(
context,
err,
);
},
);
}
}
}
class _ChatContentPage extends StatefulWidget {

View File

@ -67,8 +67,7 @@ class _ChatInputState extends State<ChatInput> {
void initState() {
super.initState();
_textController =
widget.options.textEditingController ?? InputTextFieldController();
_textController = InputTextFieldController();
_handleSendButtonVisibilityModeChange();
}
@ -85,9 +84,7 @@ class _ChatInputState extends State<ChatInput> {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
if (widget.options.inputClearMode == InputClearMode.always) {
_textController.clear();
}
_textController.clear();
}
}
@ -106,7 +103,6 @@ class _ChatInputState extends State<ChatInput> {
const inputPadding = EdgeInsets.all(6);
return Focus(
autofocus: !widget.options.autofocus,
child: Padding(
padding: inputPadding,
child: Material(
@ -148,15 +144,11 @@ class _ChatInputState extends State<ChatInput> {
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
),
autocorrect: widget.options.autocorrect,
autofocus: widget.options.autofocus,
enableSuggestions: widget.options.enableSuggestions,
keyboardType: widget.options.keyboardType,
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
minLines: 1,
onChanged: widget.options.onTextChanged,
onTap: widget.options.onTextFieldTap,
onChanged: (_) {},
),
);
}
@ -207,53 +199,6 @@ class _ChatInputState extends State<ChatInput> {
);
}
@immutable
class InputOptions {
const InputOptions({
this.inputClearMode = InputClearMode.always,
this.keyboardType = TextInputType.multiline,
this.onTextChanged,
this.onTextFieldTap,
this.textEditingController,
this.autocorrect = true,
this.autofocus = false,
this.enableSuggestions = true,
this.enabled = true,
});
/// Controls the [ChatInput] clear behavior. Defaults to [InputClearMode.always].
final InputClearMode inputClearMode;
/// Controls the [ChatInput] keyboard type. Defaults to [TextInputType.multiline].
final TextInputType keyboardType;
/// Will be called whenever the text inside [TextField] changes.
final void Function(String)? onTextChanged;
/// Will be called on [TextField] tap.
final VoidCallback? onTextFieldTap;
/// Custom [TextEditingController]. If not provided, defaults to the
/// [InputTextFieldController], which extends [TextEditingController] and has
/// additional fatures like markdown support. If you want to keep additional
/// features but still need some methods from the default [TextEditingController],
/// you can create your own [InputTextFieldController] (imported from this lib)
/// and pass it here.
final TextEditingController? textEditingController;
/// Controls the [TextInput] autocorrect behavior. Defaults to [true].
final bool autocorrect;
/// Whether [TextInput] should have focus. Defaults to [false].
final bool autofocus;
/// Controls the [TextInput] enableSuggestions behavior. Defaults to [true].
final bool enableSuggestions;
/// Controls the [TextInput] enabled behavior. Defaults to [true].
final bool enabled;
}
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;

View File

@ -6,7 +6,6 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class RelatedQuestionList extends StatelessWidget {
const RelatedQuestionList({
required this.chatId,
@ -97,6 +96,7 @@ class _RelatedQuestionItemState extends State<RelatedQuestionItem> {
style: TextStyle(
color: _isHovered ? Theme.of(context).colorScheme.primary : null,
fontSize: 14,
height: 1.5,
),
),
onTap: () {

View File

@ -4,6 +4,7 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
typedef FeatureFlagMap = Map<FeatureFlag, bool>;
@ -91,7 +92,7 @@ enum FeatureFlag {
bool get isOn {
if ([
// if (kDebugMode) FeatureFlag.planBilling,
if (kDebugMode) FeatureFlag.planBilling,
// release this feature in version 0.6.1
FeatureFlag.spaceDesign,
// release this feature in version 0.5.9

View File

@ -7,6 +7,7 @@ import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/m
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@ -128,7 +129,7 @@ class _LocalAIOnBoarding extends StatelessWidget {
child: BlocBuilder<LocalAIOnBoardingBloc, LocalAIOnBoardingState>(
builder: (context, state) {
// Show the local AI settings if the user has purchased the AI Local plan
if (state.isPurchaseAILocal) {
if (kDebugMode || state.isPurchaseAILocal) {
return const LocalAISetting();
} else {
// Show the upgrade to AI Local plan button if the user has not purchased the AI Local plan

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/util/int64_extension.dart';
@ -209,22 +211,26 @@ class _SettingsBillingViewState extends State<SettingsBillingView> {
),
),
const SettingsDashedDivider(),
_AITile(
plan: SubscriptionPlanPB.AiLocal,
label: LocaleKeys
.settings_billingPage_addons_aiOnDevice_label
.tr(),
description: LocaleKeys
.settings_billingPage_addons_aiOnDevice_description,
activeDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_activeDescription,
canceledDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_canceledDescription,
subscriptionInfo:
state.subscriptionInfo.addOns.firstWhereOrNull(
(a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
// Currently, the AI Local tile is only available on macOS
// TODO(nathan): enable windows and linux
if (Platform.isMacOS)
_AITile(
plan: SubscriptionPlanPB.AiLocal,
label: LocaleKeys
.settings_billingPage_addons_aiOnDevice_label
.tr(),
description: LocaleKeys
.settings_billingPage_addons_aiOnDevice_description,
activeDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_activeDescription,
canceledDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_canceledDescription,
subscriptionInfo:
state.subscriptionInfo.addOns.firstWhereOrNull(
(a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
),
),
),
],
),
],

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@ -136,38 +138,46 @@ class _SettingsPlanViewState extends State<SettingsPlanView> {
),
),
const HSpace(8),
Flexible(
child: _AddOnBox(
title: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_title
.tr(),
description: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_description
.tr(),
price: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_price
.tr(
args: [SubscriptionPlanPB.AiLocal.priceAnnualBilling],
// Currently, the AI Local tile is only available on macOS
// TODO(nathan): enable windows and linux
if (Platform.isMacOS)
Flexible(
child: _AddOnBox(
title: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_title
.tr(),
description: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_description
.tr(),
price: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_price
.tr(
args: [
SubscriptionPlanPB.AiLocal.priceAnnualBilling,
],
),
priceInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_priceInfo
.tr(),
billingInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_billingInfo
.tr(
args: [
SubscriptionPlanPB.AiLocal.priceMonthBilling,
],
),
buttonText: state.subscriptionInfo.hasAIOnDevice
? LocaleKeys
.settings_planPage_planUsage_addons_activeLabel
.tr()
: LocaleKeys
.settings_planPage_planUsage_addons_addLabel
.tr(),
isActive: state.subscriptionInfo.hasAIOnDevice,
plan: SubscriptionPlanPB.AiLocal,
),
priceInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_priceInfo
.tr(),
billingInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_billingInfo
.tr(
args: [SubscriptionPlanPB.AiLocal.priceMonthBilling],
),
buttonText: state.subscriptionInfo.hasAIOnDevice
? LocaleKeys
.settings_planPage_planUsage_addons_activeLabel
.tr()
: LocaleKeys
.settings_planPage_planUsage_addons_addLabel
.tr(),
isActive: state.subscriptionInfo.hasAIOnDevice,
plan: SubscriptionPlanPB.AiLocal,
),
),
],
),
],

View File

@ -169,7 +169,10 @@
"question2": "Explain the GTD method",
"question3": "Why use Rust",
"question4": "Recipe with what's in my kitchen",
"aiMistakePrompt": "AI can make mistakes. Check important info."
"aiMistakePrompt": "AI can make mistakes. Check important info.",
"chatWithFilePrompt": "Do you want to chat with the file?",
"indexFileSuccess": "Indexing file successfully",
"indexingFile": "Indexing {}"
},
"trash": {
"text": "Trash",

View File

@ -12,7 +12,7 @@ use crate::entities::*;
use crate::local_ai::local_llm_chat::LLMModelInfo;
use crate::notification::{make_notification, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY};
use crate::tools::AITools;
use flowy_error::{FlowyError, FlowyResult};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
use lib_infra::isolate_stream::IsolateSink;
@ -208,6 +208,25 @@ pub(crate) async fn chat_file_handler(
) -> Result<(), FlowyError> {
let data = data.try_into_inner()?;
let file_path = PathBuf::from(&data.file_path);
let allowed_extensions = ["pdf", "md", "txt"];
let extension = file_path
.extension()
.and_then(|ext| ext.to_str())
.ok_or_else(|| {
FlowyError::new(
ErrorCode::UnsupportedFileFormat,
"Can't find file extension",
)
})?;
if !allowed_extensions.contains(&extension) {
return Err(FlowyError::new(
ErrorCode::UnsupportedFileFormat,
"Only support pdf,md and txt",
));
}
let (tx, rx) = oneshot::channel::<Result<(), FlowyError>>();
tokio::spawn(async move {
let chat_manager = upgrade_chat_manager(chat_manager)?;

View File

@ -150,7 +150,8 @@ impl LocalAIController {
pub fn is_rag_enabled(&self) -> bool {
self
.store_preferences
.get_bool_or_default(APPFLOWY_LOCAL_AI_CHAT_RAG_ENABLED)
.get_bool(APPFLOWY_LOCAL_AI_CHAT_RAG_ENABLED)
.unwrap_or(true)
}
pub fn open_chat(&self, chat_id: &str) {

View File

@ -151,7 +151,6 @@ impl DocumentManager {
}
}
#[tracing::instrument(level = "info", skip(self), err)]
pub async fn get_document(&self, doc_id: &str) -> FlowyResult<Arc<MutexDocument>> {
if let Some(doc) = self.documents.get(doc_id).map(|item| item.value().clone()) {
return Ok(doc);
@ -160,7 +159,7 @@ impl DocumentManager {
if let Some(doc) = self.restore_document_from_removing(doc_id) {
return Ok(doc);
}
return Err(FlowyError::internal().with_context("Call open document first"));
Err(FlowyError::internal().with_context("Call open document first"))
}
/// Returns Document for given object id

View File

@ -298,6 +298,9 @@ pub enum ErrorCode {
#[error("Response timeout")]
ResponseTimeout = 103,
#[error("Unsupported file format")]
UnsupportedFileFormat = 104,
}
impl ErrorCode {