From 83cf2dd31f10a500efca270b404c0a8d98448a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Thu, 25 Jul 2024 23:33:03 +0800 Subject: [PATCH] feat: ChatGPT like `memories` (#113) Fixes #110 --- .github/FUNDING.yml | 14 +++++ README.md | 9 ++++ README_zh.md | 11 +++- lib/core/util/tool_func/func/http.dart | 6 ++- lib/core/util/tool_func/func/iface.dart | 4 +- lib/core/util/tool_func/func/memory.dart | 43 +++++++++++++++ lib/core/util/tool_func/tool.dart | 17 +++--- lib/data/res/openai.dart | 5 +- lib/data/store/tool.dart | 5 ++ lib/intro.dart | 16 +++--- lib/l10n/app_en.arb | 6 ++- lib/l10n/app_zh.arb | 6 ++- lib/view/page/backup/impl/gpt_next.dart | 5 ++ lib/view/page/backup/impl/openai.dart | 5 ++ lib/view/page/home/history.dart | 2 +- lib/view/page/home/home.dart | 6 +-- lib/view/page/home/req.dart | 9 ++-- lib/view/page/tool.dart | 66 ++++++++++++++++-------- pubspec.lock | 4 +- pubspec.yaml | 2 +- 20 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 lib/core/util/tool_func/func/memory.dart diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a41794d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,14 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: lollipopkit # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/README.md b/README.md index c8a2138..401878e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A third-party GPT Client for OpenAI API.

+ donation lang license

@@ -18,6 +19,7 @@ Please refrain from using it in production environments or for critical data. ## 🪄 Features +- (🥳 New) Ask GPT to add Memories. - (🥳 New) Api supports viewing the content of Http links, (developing) running JS scripts locally. [Video](https://cdn.lolli.tech/gptbox/screenshot/tools.mp4) - Restore from [ChatGPT Next Web backup](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web) / [OpenAI exported file](https://chatgpt.com). - Text / Image / Audio chat. @@ -68,10 +70,17 @@ After you read the above, you can: - Any positive contribution is welcome. - [l10n guide](https://blog.lolli.tech/faq/) can be found in my blog. + ## 💡 My other apps - [Server Box](https://github.com/lollipopkit/flutter_server_box) - Server status & tools. - [More](https://github.com/lollipopkit) - Tools & etc. + +## 🎉 Donation +- App will keep free, but if you think my work is helpful, you can [donate](https://ko-fi.com/lollipopkit) me a cup of coffee. +- Thanks to all the donators, your support is my motivation. + + ## 📝 License `GPL v3 lollipopkit` diff --git a/README_zh.md b/README_zh.md index 7dd5e78..f998aa8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,8 +8,9 @@

- lang - license + 捐赠 + 语言 + 证书

## 😣 注意 @@ -18,6 +19,7 @@ ## 🪄 特性 +- (🥳 新) 让 GPT 记住某些事 - (🥳 新) Api 支持查看 Http 链接的内容、(开发中)本地运行 JS 脚本。[视频](https://cdn.lolli.tech/gptbox/screenshot/tools.mp4) - 从 [ChatGPT Next Web 备份](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web) / [OpenAI导出文件](https://chatgpt.com) 恢复 - 文本 / 图片 / 音频聊天 @@ -77,5 +79,10 @@ Linux & Windows | [Github](https://github.com/lollipopkit/flutter_gpt_box/releas - [更多](https://github.com/lollipopkit) - 工具 & etc. +## 🎉 捐赠 +- 应用将保持免费,但是如果你认为我的工作对你有帮助,欢迎[捐赠](https://ko-fi.com/lollipopkit)。 +- 感谢所有捐赠者,你们的支持是我的动力。 + + ## 📝 协议 `GPL v3 lollipopkit` diff --git a/lib/core/util/tool_func/func/http.dart b/lib/core/util/tool_func/func/http.dart index 8d315a4..556cb8a 100644 --- a/lib/core/util/tool_func/func/http.dart +++ b/lib/core/util/tool_func/func/http.dart @@ -1,7 +1,9 @@ part of '../tool.dart'; -final class _HttpReq extends ToolFunc { - const _HttpReq() +final class TfHttpReq extends ToolFunc { + static const instance = TfHttpReq._(); + + const TfHttpReq._() : super( name: 'httpReq', description: ''' diff --git a/lib/core/util/tool_func/func/iface.dart b/lib/core/util/tool_func/func/iface.dart index ca1752f..70e3b0f 100644 --- a/lib/core/util/tool_func/func/iface.dart +++ b/lib/core/util/tool_func/func/iface.dart @@ -2,12 +2,12 @@ part of '../tool.dart'; abstract final class ToolFunc { final String name; - final String? description; + final String description; final _Map parametersSchema; const ToolFunc({ required this.name, - this.description, + required this.description, required this.parametersSchema, }); diff --git a/lib/core/util/tool_func/func/memory.dart b/lib/core/util/tool_func/func/memory.dart new file mode 100644 index 0000000..8de2474 --- /dev/null +++ b/lib/core/util/tool_func/func/memory.dart @@ -0,0 +1,43 @@ +part of '../tool.dart'; + +final class TfMemory extends ToolFunc { + static const instance = TfMemory._(); + + const TfMemory._() + : super( + name: 'memory', + description: ''' +Memorise the input and add what memorised to the prompt. +If users want to memorise something, you(AI models) should call this function.''', + parametersSchema: const { + 'type': 'object', + 'properties': { + 'memory': { + 'type': 'string', + 'description': 'What to memorise, will be persisted in db.', + }, + }, + }, + ); + + @override + String get l10nName => l10n.memory; + + @override + String help(_CallResp call, _Map args) { + return l10n.memoryTip(args['memory'] as String? ?? ''); + } + + @override + Future<_Ret> run(_CallResp call, _Map args, OnToolLog log) async { + final memory = args['memory'] as String?; + if (memory == null) { + return [ChatContent.text(l10n.empty)]; + } + final prop = Stores.tool.memories; + final memories = prop.fetch(); + prop.put(memories..add(memory)); + await Future.delayed(Durations.medium1); + return [ChatContent.text(l10n.memoryAdded(memory))]; + } +} diff --git a/lib/core/util/tool_func/tool.dart b/lib/core/util/tool_func/tool.dart index 9b4dd07..0731628 100644 --- a/lib/core/util/tool_func/tool.dart +++ b/lib/core/util/tool_func/tool.dart @@ -14,15 +14,18 @@ part 'type.dart'; part 'func/iface.dart'; part 'func/http.dart'; part 'func/js.dart'; +part 'func/memory.dart'; abstract final class OpenAIFuncCalls { static const internalTools = [ - _HttpReq(), + TfHttpReq.instance, + TfMemory.instance, //_RunJS(), ]; static List get tools { final tools = []; + if (!Stores.tool.enabled.fetch()) return tools; final enabledTools = Stores.tool.enabledTools.fetch(); for (final tool in internalTools) { if (enabledTools.contains(tool.name)) { @@ -37,17 +40,17 @@ abstract final class OpenAIFuncCalls { ToolConfirm askConfirm, OnToolLog onToolLog, ) async { - final tool = tools.firstWhere((t) => t.type == resp.type); - switch (tool.type) { + switch (resp.type) { case 'function': - final fn = tool.function; + final targetName = resp.function.name; + final func = + internalTools.firstWhereOrNull((e) => e.name == targetName); + if (func == null) throw 'Unknown function $targetName'; final args = await _parseMap(resp.function.arguments); - final func = internalTools.firstWhereOrNull((e) => e.name == fn.name); - if (func == null) throw 'Unknown function ${fn.name}'; if (!await askConfirm(func, func.help(resp, args))) return null; return await func.run(resp, args, onToolLog); default: - throw 'Unknown tool type ${tool.type}'; + throw 'Unknown tool type ${resp.type}'; } } } diff --git a/lib/data/res/openai.dart b/lib/data/res/openai.dart index 27a90d3..5b9a7e8 100644 --- a/lib/data/res/openai.dart +++ b/lib/data/res/openai.dart @@ -36,13 +36,12 @@ abstract final class OpenAICfg { } static RegExp? _modelsUseToolReExp; - static bool canUseTool(String model) { + static bool isToolCompatible({String? model}) { + model ??= current.model; if (model.isEmpty) return false; return _modelsUseToolReExp?.hasMatch(model) ?? false; } - static bool get canUseToolNow => canUseTool(current.model); - static Future updateModels({bool force = false}) async { if (current.url.startsWith('https://api.openai.com') && current.key.isEmpty) { diff --git a/lib/data/store/tool.dart b/lib/data/store/tool.dart index 804bd29..6728738 100644 --- a/lib/data/store/tool.dart +++ b/lib/data/store/tool.dart @@ -17,4 +17,9 @@ final class ToolStore extends PersistentStore { /// Tools that are permitted to be used by the user. /// A dialog will be shown if the tool has not been permitted. late final permittedTools = property('permittedTools', []); + + /// Memories that are saved by the user. + /// It will be added to prompt when sending a chat req. + /// {id: memory} + late final memories = property('memories', []); } diff --git a/lib/intro.dart b/lib/intro.dart index 3cd0beb..c988efd 100644 --- a/lib/intro.dart +++ b/lib/intro.dart @@ -18,13 +18,15 @@ final class _IntroPage extends StatelessWidget { final padTop = cons.maxHeight * .12; final pages_ = pages.map((e) => e(context, padTop)).toList(); return IntroPage( - pages: pages_, - onDone: (ctx) { - Stores.setting.introVer.put(Build.build); - Navigator.of(ctx).pushReplacement( - MaterialPageRoute(builder: (_) => const HomePage()), - ); - }, + args: IntroPageArgs( + pages: pages_, + onDone: (ctx) { + Stores.setting.introVer.put(Build.build); + Navigator.of(ctx).pushReplacement( + MaterialPageRoute(builder: (_) => const HomePage()), + ); + }, + ), ); }, ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c26fd8b..cc3bfb3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -74,7 +74,11 @@ "languageName": "English", "license": "License", "licenseMenuItem": "Open-source licenses", + "list": "List", "manual": "Manual", + "memory": "Memory", + "memoryAdded": "Memory added: {str}", + "memoryTip": "Memorise [{txt}]?", "message": "Message", "minute": "min", "model": "Model", @@ -125,6 +129,7 @@ "stt": "stt", "success": "Success 🏅 ~", "sureRestoreFmt": "Are you sure to restore Backup({time})?", + "switcher": "Switch", "syncConflict": "Sync conflict: can't turn on {a} and {b} at the same time.", "system": "System", "text": "Text", @@ -132,7 +137,6 @@ "themeMode": "Theme mode", "thirdParty": "Third Party", "tool": "Tool", - "toolAvailability": "Only on gpt-4o, gpt-4t & etc.", "toolConfirmFmt": "Is it permitted to use the tool {tool} ?", "toolFinishTip": "Tools invocation completed", "toolHttpReqHelp": "It will fetch data from network. In this time, it will communicate with {host}.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5e34723..76bcbbd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -74,7 +74,11 @@ "languageName": "简体中文", "license": "许可证", "licenseMenuItem": "开放源代码许可", + "list": "列表", "manual": "手动", + "memory": "记忆", + "memoryAdded": "记忆已添加: {str}", + "memoryTip": "记住 [{txt}]?", "message": "消息", "minute": "分钟", "model": "模型", @@ -125,6 +129,7 @@ "stt": "语音转文字", "success": "成功 🏅~", "sureRestoreFmt": "确定恢复备份({time})?", + "switcher": "开关", "syncConflict": "冲突:不能同时开启 {a} 和 {b}", "system": "系统", "text": "文字", @@ -132,7 +137,6 @@ "themeMode": "主题模式", "thirdParty": "第三方", "tool": "工具", - "toolAvailability": "仅 gpt-4o、gpt-4t 等支持", "toolConfirmFmt": "是否同意使用工具 {tool} ?", "toolFinishTip": "工具调用完成", "toolHttpReqHelp": "将会与从网络获取数据,本次将会联系 {host}", diff --git a/lib/view/page/backup/impl/gpt_next.dart b/lib/view/page/backup/impl/gpt_next.dart index 164de23..6397b4c 100644 --- a/lib/view/page/backup/impl/gpt_next.dart +++ b/lib/view/page/backup/impl/gpt_next.dart @@ -37,5 +37,10 @@ void _onTapRestoreGPTNext(BuildContext context) async { ); }); + if (chats == null) { + context.showSnackBar('null'); + return; + } + _askConfirm(context, chats); } diff --git a/lib/view/page/backup/impl/openai.dart b/lib/view/page/backup/impl/openai.dart index 7942ec6..f6bb686 100644 --- a/lib/view/page/backup/impl/openai.dart +++ b/lib/view/page/backup/impl/openai.dart @@ -38,5 +38,10 @@ void _onTapRestoreOpenAI(BuildContext context) async { ); }); + if (chats == null) { + context.showSnackBar('null'); + return; + } + _askConfirm(context, chats); } diff --git a/lib/view/page/home/history.dart b/lib/view/page/home/history.dart index 04ff812..f636887 100644 --- a/lib/view/page/home/history.dart +++ b/lib/view/page/home/history.dart @@ -89,7 +89,7 @@ class _HistoryPageState extends State<_HistoryPage> children: [ IconBtn( onTap: () => _onTapRenameChat(chatId, context), - icon: Icons.abc, + icon: BoxIcons.bx_rename, ), IconBtn( onTap: () => _onTapDeleteChat(chatId, context), diff --git a/lib/view/page/home/home.dart b/lib/view/page/home/home.dart index 407cef1..a9bc765 100644 --- a/lib/view/page/home/home.dart +++ b/lib/view/page/home/home.dart @@ -86,9 +86,9 @@ class _HomePageState extends State @override Widget build(BuildContext context) { - return const ExitConfirm( - onPop: ExitConfirm.exitApp, - child: Scaffold( + return ExitConfirm( + onPop: (_) => ExitConfirm.exitApp(), + child: const Scaffold( drawer: _Drawer(), appBar: _CustomAppBar(), body: _Body(), diff --git a/lib/view/page/home/req.dart b/lib/view/page/home/req.dart index c357b04..89cd4d1 100644 --- a/lib/view/page/home/req.dart +++ b/lib/view/page/home/req.dart @@ -27,10 +27,11 @@ Iterable _historyCarried( final ignoreCtxCons = workingChat.settings?.ignoreContextConstraint == true; if (ignoreCtxCons) return workingChat.items.map((e) => e.toOpenAI); - final prompt = config.prompt.isNotEmpty + final promptStr = config.prompt + Stores.tool.memories.fetch().join('\n'); + final prompt = promptStr.isNotEmpty ? ChatHistoryItem.single( role: ChatRole.system, - raw: config.prompt, + raw: promptStr, ).toOpenAI : null; @@ -134,7 +135,7 @@ Future _onCreateText( _loadingChatIds.add(chatId); _autoScroll(chatId); - final useTools = Stores.tool.enabled.fetch() && OpenAICfg.canUseToolNow; + final toolCompatible = OpenAICfg.isToolCompatible(); // #104 final singleChatScopeUseTools = workingChat.settings?.useTools != false; @@ -145,7 +146,7 @@ Future _onCreateText( /// TODO: after switching to http img url, remove this condition. /// To save tokens, we don't use tools for image prompt - if (useTools && !hasImg && singleChatScopeUseTools && !isToolsEmpty) { + if (toolCompatible && !hasImg && singleChatScopeUseTools && !isToolsEmpty) { final toolReply = ChatHistoryItem.single(role: ChatRole.tool, raw: ''); workingChat.items.add(toolReply); _loadingChatIds.add(toolReply.id); diff --git a/lib/view/page/tool.dart b/lib/view/page/tool.dart index 7a56805..0f00062 100644 --- a/lib/view/page/tool.dart +++ b/lib/view/page/tool.dart @@ -31,13 +31,42 @@ class _ToolPageState extends State { children: [ _buildUseTool(), _buildModelRegExp(), - _buildTitle('~'), - _buildSwicthes(), + _buildTitle(l10n.list), + _buildSwitchTile(TfHttpReq.instance).cardx, + _buildMemory(), const SizedBox(height: 37), ], ); } + Widget _buildMemory() { + return ExpandTile( + title: Text(l10n.memory), + children: [ + _buildSwitchTile(TfMemory.instance, title: l10n.switcher), + ListTile( + title: Text(l10n.edit), + onTap: () async { + final data = _store.memories.fetch(); + final dataMap = {}; + for (var idx = 0; idx < data.length; idx++) { + dataMap['$idx'] = data[idx]; + } + final res = await KvEditor.route.go( + context, + args: KvEditorArgs(data: dataMap), + ); + if (res != null) { + _store.memories.put(res.values.toList()); + context.showSnackBar(l10n.success); + } + }, + trailing: const Icon(Icons.keyboard_arrow_right), + ), + ], + ).cardx; + } + Widget _buildTitle(String text) { return Padding( padding: const EdgeInsets.only(top: 23, bottom: 17), @@ -53,8 +82,7 @@ class _ToolPageState extends State { Widget _buildUseTool() { return ListTile( leading: const Icon(MingCute.tool_line), - title: Text(l10n.tool), - subtitle: Text(l10n.toolAvailability, style: UIs.textGrey), + title: Text(l10n.switcher), trailing: StoreSwitch(prop: _store.enabled), ).cardx; } @@ -96,27 +124,23 @@ class _ToolPageState extends State { ).cardx; } - Widget _buildSwicthes() { + Widget _buildSwitchTile(ToolFunc e, {String? title}) { final prop = _store.enabledTools; return ValBuilder( listenable: prop.listenable(), builder: (vals) { - return Column( - children: OpenAIFuncCalls.internalTools.map((e) { - return ListTile( - title: Text(e.name), - subtitle: Text(e.l10nName), - trailing: Switch( - value: vals.contains(e.name), - onChanged: (val) { - final _ = switch (val) { - true => prop.put(vals..add(e.name)), - false => prop.put(vals..remove(e.name)), - }; - }, - ), - ); - }).toList(), + final name = e.name; + return ListTile( + title: Text(title ?? e.l10nName), + trailing: Switch( + value: vals.contains(name), + onChanged: (val) { + final _ = switch (val) { + true => prop.put(vals..add(name)), + false => prop.put(vals..remove(name)), + }; + }, + ), ); }, ); diff --git a/pubspec.lock b/pubspec.lock index 457571d..e20de35 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -407,8 +407,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.0.79" - resolved-ref: f1bc7dd5ec2af84813b33a9e2149e117dfea3cd0 + ref: "v1.0.88" + resolved-ref: fd87becb3c112edac090903e78d863b5ca93a70e url: "https://github.com/lppcg/fl_lib" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index a2cf701..7159d1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: fl_lib: git: url: https://github.com/lppcg/fl_lib - ref: v1.0.79 + ref: v1.0.88 dependency_overrides: # fl_lib: