fix: chat bugs (#5823)

* chore: implement slash menu

* chore: show popup

* chore: single column

* chore: update appflowy editor

* chore: fix warns
This commit is contained in:
Nathan.fooo 2024-07-27 23:47:08 +08:00 committed by GitHub
parent ddf68b010d
commit d24f1c566a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 521 additions and 153 deletions

View File

@ -13,9 +13,9 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
: listener = LocalLLMListener(),
super(const ChatInputState(aiType: _AppFlowyAI())) {
listener.start(
stateCallback: (pluginState) {
chatStateCallback: (aiState) {
if (!isClosed) {
add(ChatInputEvent.updateState(pluginState));
add(ChatInputEvent.updateState(aiState));
}
},
);
@ -37,18 +37,26 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
) async {
await event.when(
started: () async {
final result = await ChatEventGetLocalAIPluginState().send();
final result = await ChatEventGetLocalAIChatState().send();
result.fold(
(pluginState) {
(aiState) {
if (!isClosed) {
add(ChatInputEvent.updateState(pluginState));
add(
ChatInputEvent.updateState(aiState),
);
}
},
(err) => Log.error(err.toString()),
(err) {
Log.error(err.toString());
},
);
},
updateState: (LocalAIPluginStatePB aiPluginState) {
emit(const ChatInputState(aiType: _AppFlowyAI()));
updateState: (aiState) {
if (aiState.enabled) {
emit(const ChatInputState(aiType: _LocalAI()));
} else {
emit(const ChatInputState(aiType: _AppFlowyAI()));
}
},
);
}
@ -57,8 +65,8 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
@freezed
class ChatInputEvent with _$ChatInputEvent {
const factory ChatInputEvent.started() = _Started;
const factory ChatInputEvent.updateState(LocalAIPluginStatePB aiPluginState) =
_UpdatePluginState;
const factory ChatInputEvent.updateState(LocalAIChatPB aiState) =
_UpdateAIState;
}
@freezed

View File

@ -23,7 +23,7 @@ import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
import 'presentation/chat_input.dart';
import 'presentation/chat_input/chat_input.dart';
import 'presentation/chat_popmenu.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart';
@ -82,7 +82,9 @@ class AIChatPage extends StatelessWidget {
userProfile: userProfile,
)..add(const ChatEvent.initialLoad()),
),
BlocProvider(create: (_) => ChatInputBloc()),
BlocProvider(
create: (_) => ChatInputBloc()..add(const ChatInputEvent.started()),
),
],
child: BlocListener<ChatFileBloc, ChatFileState>(
listenWhen: (previous, current) =>
@ -391,36 +393,39 @@ class _ChatContentPageState extends State<_ChatContentPage> {
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocBuilder<ChatInputBloc, ChatInputState>(
builder: (context, state) {
return state.aiType.when(
appflowyAI: () => Column(
children: [
BlocSelector<ChatBloc, ChatState, LoadingState>(
selector: (state) => state.streamingStatus,
builder: (context, state) {
return ChatInput(
chatId: widget.view.id,
onSendPressed: (message) =>
onSendPressed(context, message.text),
isStreaming: state != const LoadingState.finish(),
onStopStreaming: () {
context
.read<ChatBloc>()
.add(const ChatEvent.stopStream());
},
);
},
final hintText = state.aiType.when(
appflowyAI: () => LocaleKeys.chat_inputMessageHint.tr(),
localAI: () => LocaleKeys.chat_inputLocalAIMessageHint.tr(),
);
return Column(
children: [
BlocSelector<ChatBloc, ChatState, LoadingState>(
selector: (state) => state.streamingStatus,
builder: (context, state) {
return ChatInput(
chatId: widget.view.id,
onSendPressed: (message) =>
onSendPressed(context, message.text),
isStreaming: state != const LoadingState.finish(),
onStopStreaming: () {
context
.read<ChatBloc>()
.add(const ChatEvent.stopStream());
},
hintText: hintText,
);
},
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_aiMistakePrompt.tr(),
fontSize: 12,
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_aiMistakePrompt.tr(),
fontSize: 12,
),
),
],
),
localAI: () => const SizedBox.shrink(),
),
],
);
},
),

View File

@ -4,7 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';

View File

@ -0,0 +1,237 @@
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
abstract class ChatActionMenuItem {
String get title;
}
abstract class ChatActionHandler {
List<ChatActionMenuItem> get items;
void onEnter();
void onSelected(ChatActionMenuItem item);
void onExit();
}
abstract class ChatAnchor {
GlobalKey get anchorKey;
LayerLink get layerLink;
}
const int _itemHeight = 34;
const int _itemVerticalPadding = 4;
class ChatActionsMenu {
ChatActionsMenu({
required this.anchor,
required this.context,
required this.handler,
required this.style,
});
final BuildContext context;
final ChatAnchor anchor;
final ChatActionsMenuStyle style;
final ChatActionHandler handler;
OverlayEntry? _overlayEntry;
void dismiss() {
_overlayEntry?.remove();
_overlayEntry = null;
handler.onExit();
}
void show() {
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
}
void _show() {
if (_overlayEntry != null) {
dismiss();
}
if (anchor.anchorKey.currentContext == null) {
return;
}
handler.onEnter();
final height = handler.items.length * (_itemHeight + _itemVerticalPadding);
_overlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
CompositedTransformFollower(
link: anchor.layerLink,
showWhenUnlinked: false,
offset: Offset(0, -height - 4),
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 200,
maxWidth: 200,
maxHeight: 200,
),
child: DecoratedBox(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6.0),
),
child: ActionList(
handler: handler,
onDismiss: () => dismiss(),
),
),
),
),
),
],
),
);
Overlay.of(context).insert(_overlayEntry!);
}
}
class _ActionItem extends StatelessWidget {
const _ActionItem({
required this.item,
required this.onTap,
required this.isSelected,
});
final ChatActionMenuItem item;
final VoidCallback? onTap;
final bool isSelected;
@override
Widget build(BuildContext context) {
return Container(
height: _itemHeight.toDouble(),
padding: const EdgeInsets.symmetric(vertical: _itemVerticalPadding / 2.0),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(4.0),
),
child: FlowyButton(
margin: const EdgeInsets.symmetric(horizontal: 6),
iconPadding: 10.0,
text: FlowyText.regular(
item.title,
),
onTap: onTap,
),
);
}
}
class ActionList extends StatefulWidget {
const ActionList({super.key, required this.handler, required this.onDismiss});
final ChatActionHandler handler;
final VoidCallback? onDismiss;
@override
State<ActionList> createState() => _ActionListState();
}
class _ActionListState extends State<ActionList> {
final FocusScopeNode _focusNode =
FocusScopeNode(debugLabel: 'ChatActionsMenu');
int _selectedIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _handleKeyPress(event) {
setState(() {
// ignore: deprecated_member_use
if (event is KeyDownEvent || event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_selectedIndex = (_selectedIndex + 1) % widget.handler.items.length;
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
_selectedIndex = (_selectedIndex - 1 + widget.handler.items.length) %
widget.handler.items.length;
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
widget.handler.onSelected(widget.handler.items[_selectedIndex]);
widget.onDismiss?.call();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss?.call();
}
}
});
}
@override
Widget build(BuildContext context) {
return FocusScope(
node: _focusNode,
onKey: (node, event) {
_handleKeyPress(event);
return KeyEventResult.handled;
},
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(8),
children: widget.handler.items.asMap().entries.map((entry) {
final index = entry.key;
final ChatActionMenuItem item = entry.value;
return _ActionItem(
item: item,
onTap: () {
widget.handler.onSelected(item);
widget.onDismiss?.call();
},
isSelected: _selectedIndex == index,
);
}).toList(),
),
);
}
}
class ChatActionsMenuStyle {
ChatActionsMenuStyle({
required this.backgroundColor,
required this.groupTextColor,
required this.menuItemTextColor,
required this.menuItemSelectedColor,
required this.menuItemSelectedTextColor,
});
const ChatActionsMenuStyle.light()
: backgroundColor = Colors.white,
groupTextColor = const Color(0xFF555555),
menuItemTextColor = const Color(0xFF333333),
menuItemSelectedColor = const Color(0xFFE0F8FF),
menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247);
const ChatActionsMenuStyle.dark()
: backgroundColor = const Color(0xFF282E3A),
groupTextColor = const Color(0xFFBBC3CD),
menuItemTextColor = const Color(0xFFBBC3CD),
menuItemSelectedColor = const Color(0xFF00BCF0),
menuItemSelectedTextColor = const Color(0xFF131720);
final Color backgroundColor;
final Color groupTextColor;
final Color menuItemTextColor;
final Color menuItemSelectedColor;
final Color menuItemSelectedTextColor;
}

View File

@ -0,0 +1,48 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
class ChatInputAccessoryButton extends StatelessWidget {
const ChatInputAccessoryButton({
required this.onSendPressed,
required this.onStopStreaming,
required this.isStreaming,
super.key,
});
final void Function() onSendPressed;
final void Function() onStopStreaming;
final bool isStreaming;
@override
Widget build(BuildContext context) {
if (isStreaming) {
return FlowyIconButton(
width: 36,
icon: FlowySvg(
FlowySvgs.ai_stream_stop_s,
size: const Size.square(28),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onStopStreaming,
radius: BorderRadius.circular(18),
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
} else {
return FlowyIconButton(
width: 36,
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onSendPressed,
);
}
}
}

View File

@ -0,0 +1,76 @@
import 'package:appflowy/plugins/ai_chat/presentation/chat_inline_action_menu.dart';
import 'package:flutter/material.dart';
class ChatTextFieldInterceptor {
String previosText = "";
ChatActionHandler? onTextChanged(
String text,
TextEditingController textController,
FocusNode textFieldFocusNode,
) {
if (previosText == "/" && text == "/ ") {
final handler = IndexActionHandler(
textController: textController,
textFieldFocusNode: textFieldFocusNode,
) as ChatActionHandler;
return handler;
}
previosText = text;
return null;
}
}
class FixGrammarMenuItem extends ChatActionMenuItem {
@override
String get title => "Fix Grammar";
}
class ImproveWritingMenuItem extends ChatActionMenuItem {
@override
String get title => "Improve Writing";
}
class ChatWithFileMenuItem extends ChatActionMenuItem {
@override
String get title => "Chat With PDF";
}
class IndexActionHandler extends ChatActionHandler {
IndexActionHandler({
required this.textController,
required this.textFieldFocusNode,
});
final TextEditingController textController;
final FocusNode textFieldFocusNode;
@override
List<ChatActionMenuItem> get items => [
ChatWithFileMenuItem(),
FixGrammarMenuItem(),
ImproveWritingMenuItem(),
];
@override
void onSelected(ChatActionMenuItem item) {
textController.clear();
WidgetsBinding.instance.addPostFrameCallback(
(_) => textFieldFocusNode.requestFocus(),
);
}
@override
void onExit() {
if (!textFieldFocusNode.hasFocus) {
textFieldFocusNode.requestFocus();
}
}
@override
void onEnter() {
if (textFieldFocusNode.hasFocus) {
textFieldFocusNode.unfocus();
}
}
}

View File

@ -1,14 +1,13 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_inline_action_menu.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'chat_accessory_button.dart';
class ChatInput extends StatefulWidget {
/// Creates [ChatInput] widget.
const ChatInput({
@ -20,6 +19,7 @@ class ChatInput extends StatefulWidget {
this.options = const InputOptions(),
required this.isStreaming,
required this.onStopStreaming,
required this.hintText,
});
final bool? isAttachmentUploading;
@ -29,6 +29,7 @@ class ChatInput extends StatefulWidget {
final InputOptions options;
final String chatId;
final bool isStreaming;
final String hintText;
@override
State<ChatInput> createState() => _ChatInputState();
@ -36,6 +37,11 @@ class ChatInput extends StatefulWidget {
/// [ChatInput] widget state.
class _ChatInputState extends State<ChatInput> {
final GlobalKey _textFieldKey = GlobalKey();
final LayerLink _layerLink = LayerLink();
// final ChatTextFieldInterceptor _textFieldInterceptor =
// ChatTextFieldInterceptor();
late final _inputFocusNode = FocusNode(
onKeyEvent: (node, event) {
if (event.physicalKey == PhysicalKeyboardKey.enter &&
@ -59,9 +65,9 @@ class _ChatInputState extends State<ChatInput> {
}
},
);
late TextEditingController _textController;
bool _sendButtonVisible = false;
late TextEditingController _textController;
@override
void initState() {
@ -71,33 +77,15 @@ class _ChatInputState extends State<ChatInput> {
_handleSendButtonVisibilityModeChange();
}
void _handleSendButtonVisibilityModeChange() {
_textController.removeListener(_handleTextControllerChange);
_sendButtonVisible =
_textController.text.trim() != '' || widget.isStreaming;
_textController.addListener(_handleTextControllerChange);
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
super.dispose();
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
_textController.clear();
}
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
setState(() {
_sendButtonVisible = _textController.text.trim() != '';
});
}
Widget _inputBuilder() {
@override
Widget build(BuildContext context) {
const textPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
const buttonPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
const inputPadding = EdgeInsets.all(6);
@ -128,27 +116,78 @@ class _ChatInputState extends State<ChatInput> {
);
}
Padding _inputTextField(EdgeInsets textPadding) {
return Padding(
padding: textPadding,
child: TextField(
controller: _textController,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
hintStyle: TextStyle(
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
void _handleSendButtonVisibilityModeChange() {
_textController.removeListener(_handleTextControllerChange);
_sendButtonVisible =
_textController.text.trim() != '' || widget.isStreaming;
_textController.addListener(_handleTextControllerChange);
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
_textController.clear();
}
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
setState(() {
_sendButtonVisible = _textController.text.trim() != '';
});
}
Widget _inputTextField(EdgeInsets textPadding) {
return CompositedTransformTarget(
link: _layerLink,
child: Padding(
padding: textPadding,
child: TextField(
key: _textFieldKey,
controller: _textController,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
hintText: widget.hintText,
hintStyle: TextStyle(
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
),
),
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
minLines: 1,
// onChanged: (text) {
// final handler = _textFieldInterceptor.onTextChanged(
// text,
// _textController,
// _inputFocusNode,
// );
// // If the handler is not null, it means that the text has been
// // recognized as a command.
// if (handler != null) {
// ChatActionsMenu(
// anchor: ChatInputAnchor(
// anchorKey: _textFieldKey,
// layerLink: _layerLink,
// ),
// handler: handler,
// context: context,
// style: Theme.of(context).brightness == Brightness.dark
// ? const ChatActionsMenuStyle.dark()
// : const ChatActionsMenuStyle.light(),
// ).show();
// }
// },
),
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
minLines: 1,
onChanged: (_) {},
),
);
}
@ -162,16 +201,14 @@ class _ChatInputState extends State<ChatInput> {
visible: _sendButtonVisible,
child: Padding(
padding: buttonPadding,
child: AccessoryButton(
child: ChatInputAccessoryButton(
onSendPressed: () {
if (!widget.isStreaming) {
widget.onStopStreaming();
_handleSendPressed();
}
},
onStopStreaming: () {
widget.onStopStreaming();
},
onStopStreaming: () => widget.onStopStreaming(),
isStreaming: widget.isStreaming,
),
),
@ -184,64 +221,20 @@ class _ChatInputState extends State<ChatInput> {
super.didUpdateWidget(oldWidget);
_handleSendButtonVisibilityModeChange();
}
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () => _inputFocusNode.requestFocus(),
child: _inputBuilder(),
);
}
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
class AccessoryButton extends StatelessWidget {
const AccessoryButton({
required this.onSendPressed,
required this.onStopStreaming,
required this.isStreaming,
super.key,
class ChatInputAnchor extends ChatAnchor {
ChatInputAnchor({
required this.anchorKey,
required this.layerLink,
});
final void Function() onSendPressed;
final void Function() onStopStreaming;
final bool isStreaming;
@override
final GlobalKey<State<StatefulWidget>> anchorKey;
@override
Widget build(BuildContext context) {
if (isStreaming) {
return FlowyIconButton(
width: 36,
icon: FlowySvg(
FlowySvgs.ai_stream_stop_s,
size: const Size.square(28),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onStopStreaming,
radius: BorderRadius.circular(18),
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
} else {
return FlowyIconButton(
width: 36,
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onSendPressed,
);
}
}
final LayerLink layerLink;
}

View File

@ -5,7 +5,7 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'chat_input.dart';
import 'chat_input/chat_input.dart';
class ChatWelcomePage extends StatelessWidget {
ChatWelcomePage({required this.onSelectedQuestion, super.key});

View File

@ -53,8 +53,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "268aae9"
resolved-ref: "268aae905b18efc8a3a9c88dc75ebd19b314bd43"
ref: aac7729
resolved-ref: aac77292a1a175fd7450eef30167032d3cec7fea
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "3.1.0"
@ -1670,10 +1670,10 @@ packages:
dependency: transitive
description:
name: qr
sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3"
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
realtime_client:
dependency: transitive
description:
@ -2443,5 +2443,5 @@ packages:
source: hosted
version: "2.0.0"
sdks:
dart: ">=3.3.0 <4.0.0"
dart: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"

View File

@ -194,7 +194,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "268aae9"
ref: "aac7729"
appflowy_editor_plugins:
git:

View File

@ -159,6 +159,7 @@
"chat": {
"newChat": "AI Chat",
"inputMessageHint": "Message @:appName AI",
"inputLocalAIMessageHint": "Message @:appName Local AI",
"unsupportedCloudPrompt": "This feature is only available when using @:appName Cloud",
"relatedQuestion": "Related",
"serverUnavailable": "Service Temporarily Unavailable. Please try again later.",