From e55e44590631e51c87294cecd7af39796629b96d Mon Sep 17 00:00:00 2001 From: zl-q Date: Wed, 11 Mar 2026 09:14:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E9=9B=86=E6=88=90=20LiteLLM=20?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增好友搜索、添加、好友列表功能 - 集成 LiteLLM 代理服务及多模型定价配置 - 更新 iOS CocoaPods 配置 - 更新 .gitignore 和环境变量配置 --- .DS_Store | Bin 0 -> 10244 bytes .env.example | 5 + .gitignore | 10 +- .tmp/litellm-proxy-config.yaml | 11 + AGENTS.md | 2 +- apps/ios/Runner.xcodeproj/project.pbxproj | 112 ++++ apps/lib/core/di/injection.dart | 4 + .../contacts/ui/screens/contacts_screen.dart | 545 +++++++++++++++--- .../features/friends/data/friends_api.dart | 96 +++ .../settings/ui/screens/settings_screen.dart | 34 +- backend/.DS_Store | Bin 0 -> 8196 bytes backend/AGENTS.md | 18 +- backend/scripts/build_litellm_proxy_config.py | 90 +++ backend/src/.DS_Store | Bin 0 -> 8196 bytes backend/src/core/.DS_Store | Bin 0 -> 8196 bytes backend/src/core/config/.DS_Store | Bin 0 -> 6148 bytes backend/src/core/config/initial/init_data.py | 2 + backend/src/core/config/settings.py | 12 + backend/src/core/config/static/.DS_Store | Bin 0 -> 6148 bytes .../config/static/database/llm_catalog.yaml | 68 ++- backend/src/services/litellm/__init__.py | 9 + backend/src/services/litellm/service.py | 189 ++++++ .../tests/unit/core/agent/test_init_data.py | 23 + .../unit/services/test_litellm_service.py | 55 ++ infra/mail-templates/confirmation.html | 21 - infra/mail-templates/recovery.html | 21 - infra/scripts/app.sh | 78 ++- pyproject.toml | 2 +- 28 files changed, 1226 insertions(+), 181 deletions(-) create mode 100644 .DS_Store create mode 100644 .tmp/litellm-proxy-config.yaml create mode 100644 apps/lib/features/friends/data/friends_api.dart create mode 100644 backend/.DS_Store create mode 100644 backend/scripts/build_litellm_proxy_config.py create mode 100644 backend/src/.DS_Store create mode 100644 backend/src/core/.DS_Store create mode 100644 backend/src/core/config/.DS_Store create mode 100644 backend/src/core/config/static/.DS_Store create mode 100644 backend/src/services/litellm/__init__.py create mode 100644 backend/src/services/litellm/service.py create mode 100644 backend/tests/unit/services/test_litellm_service.py delete mode 100644 infra/mail-templates/confirmation.html delete mode 100644 infra/mail-templates/recovery.html diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..43dabe1a48e75b8aef203ec108c0f6d0dc173910 GIT binary patch literal 10244 zcmeHMO>7%Q6n^76#BP4-q)A%@2)Diy>T{K@0i`S z(+~yu!Ub+zxN+f(kT`JzPMqM#l>_&R1DwnEX4lDj?EshZ6U|sVZ+7N=Gw;2bd2eS- zM55TL-5|;mQ3?;!%qX$}iQD<f5_rtS_o+;pvrY1Q%3;MB&tZ&-vU=r zm8LvZNm4s{ewWpXEh}i{4OPU3`rwI5s*5BLCOOnzw4;0iHwYpT;T!GpDf$R^0}X1D z&G!RQ9QRN82#))&lSO`v@2eQ!`7R%L>ySs{{vp=4xE8~(wh~gX{H%4Y5An%gBP#n| zGqlgwm8NZZ{_N~eB5^J`^vrN-I5m=bSlW^MCExH{UeRzj_-$26yV5Nil?Rr&U(QV3 zm5y&%j_E3ZRWre|`Htn(K!;v3B3nURbOd1Q`kMoaF8ej{n+72}nKXf(wIg{z?mOei+ zIyQbGGckE_VrpV~div7kE6+|o_k1~Xv0_yB>MqNCSK87o+AXu3x!^(b#j13cU9%01 z*L?GlU$Wju(_{>=V%FY0saic~f@kWaLUnrIHBD!u?pn2_?QPHW*COS09U_rP>L&_2xCy5YKpQ#XqT4{f7j+DlGR`o3(eSd>e3Iekgr(U0^C{Z3EBn7AaaiR)rU%!xaqAm+u2*brO75W4|B(VgxW!u~=c zJQM~@ce~^|wsh)fW#naSjwWv+dRc9b1~x3VVfg0Iw%TZUI#65L?%ke=1bT>W<1y9S z0q`by$jj){z_SzIatSx)U`Bv+sfF>jPe&=Y^f=-+hJa!x*5{bG{~pQMaqKI1u?M+W zUr5-ijc7uq7bCq7#Mt#4rK)Uz_c#sYYsiI#&|##$`LNab?Thc{fjEV?_P2SPJALD) zv3~B;J$jo~v5xYX!CJ3o%H4hz(JYBsY2p{N0I|D`@(oi(&EKA~ajaM6X#a%!jJV90 z4Ya#FyY4bJ)iGC@#FvAFGmC8+!Cw#2ZE(z|m&(~)D#w!?TO>n>mmD?mw^*+aR /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +323,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + A5E6C420AAAF89A8795B6C9E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C6D974DBE54952499F275A1B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -378,6 +487,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 990B7AE4995B1DA3D21F475A /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +505,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +521,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 8b34be6..e1ab127 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -11,6 +11,7 @@ import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/data/auth_repository_impl.dart'; import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/calendar/ui/calendar_state_manager.dart'; +import '../../features/friends/data/friends_api.dart'; import '../../features/users/data/users_api.dart'; final sl = GetIt.instance; @@ -44,6 +45,9 @@ Future configureDependencies() async { final usersApi = UsersApi(apiClient); sl.registerSingleton(usersApi); + final friendsApi = FriendsApi(apiClient); + sl.registerSingleton(friendsApi); + final authRepository = AuthRepositoryImpl( api: authApi, tokenStorage: tokenStorage, diff --git a/apps/lib/features/contacts/ui/screens/contacts_screen.dart b/apps/lib/features/contacts/ui/screens/contacts_screen.dart index 97c4761..b4a0007 100644 --- a/apps/lib/features/contacts/ui/screens/contacts_screen.dart +++ b/apps/lib/features/contacts/ui/screens/contacts_screen.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; +import '../../../../shared/widgets/toast/index.dart'; import '../../../../shared/widgets/page_header.dart' as widgets; +import '../../../friends/data/friends_api.dart'; +import '../../../users/data/models/user_response.dart'; +import '../../../users/data/users_api.dart'; class ContactsScreen extends StatefulWidget { const ContactsScreen({super.key}); @@ -12,37 +17,156 @@ class ContactsScreen extends StatefulWidget { class _ContactsScreenState extends State { final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); - final List _recentContacts = [ - ContactItem( - name: 'Toki', - email: 'toki@xunmee.com', - color: AppColors.blue500, - ), - ContactItem( - name: 'Mina', - email: 'mina@xunmee.com', - color: AppColors.violet600, - ), - ]; + List _friends = []; + List _searchResults = []; + bool _isLoading = true; + bool _isSearching = false; + bool _hasSearched = false; + final Set _sentRequestIds = {}; + Set _friendIds = {}; - final List _allContacts = [ - ContactItem(name: 'Aki', email: 'aki@xunmee.com', color: AppColors.blue600), - ContactItem( - name: 'Lynn', - email: 'lynn@xunmee.com', - color: const Color(0xFF0EA5E9), - ), - ContactItem( - name: 'Nora', - email: 'nora@xunmee.com', - color: AppColors.violet500, - ), - ]; + @override + void initState() { + super.initState(); + _loadFriends(); + } + + Future _loadFriends() async { + try { + final friendsApi = sl(); + final friends = await friendsApi.getFriends(); + if (mounted) { + setState(() { + _friends = friends; + _friendIds = friends.map((f) => f.friend.id).toSet(); + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + bool _isValidEmail(String email) { + final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$'); + return emailRegex.hasMatch(email); + } + + Future _onSearch() async { + final query = _searchController.text.trim(); + + if (query.isEmpty) { + Toast.show(context, '请输入邮箱地址', type: ToastType.warning); + return; + } + + if (!_isValidEmail(query)) { + Toast.show(context, '请输入有效的邮箱地址', type: ToastType.warning); + return; + } + + setState(() { + _isSearching = true; + _searchResults = []; + _hasSearched = true; + }); + + try { + final usersApi = sl(); + final results = await usersApi.searchUsers(query); + + if (mounted) { + setState(() { + _searchResults = results; + _isSearching = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isSearching = false; + }); + Toast.show(context, '搜索失败,请稍后重试', type: ToastType.error); + } + } + } + + Future _sendFriendRequest(String targetUserId, String? content) async { + try { + final friendsApi = sl(); + await friendsApi.sendRequest(targetUserId); + + if (mounted) { + setState(() { + _sentRequestIds.add(targetUserId); + }); + Toast.show(context, '好友请求已发送', type: ToastType.success); + } + } catch (e) { + if (mounted) { + Toast.show(context, '发送失败,请稍后重试', type: ToastType.error); + } + } + } + + void _showAddFriendDialog(UserResponse user) { + final controller = TextEditingController(); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('添加好友'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('向 ${user.username} 发送好友请求'), + const SizedBox(height: 16), + TextField( + controller: controller, + decoration: const InputDecoration( + labelText: '验证消息(可选)', + hintText: '你好,我是...', + border: OutlineInputBorder(), + ), + maxLines: 3, + maxLength: 200, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + controller.dispose(); + Navigator.pop(dialogContext); + }, + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _sendFriendRequest( + user.id, + controller.text.isEmpty ? null : controller.text, + ); + }, + child: const Text('发送'), + ), + ], + ), + ).then((_) => controller.dispose()); + } @override void dispose() { _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } @@ -61,14 +185,21 @@ class _ContactsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSearchRow(), - const SizedBox(height: 16), - _buildSectionTitle('最近联系'), - const SizedBox(height: 8), - _buildContactCard(_recentContacts), + _buildSearchResults(), const SizedBox(height: 16), _buildSectionTitle('全部联系人'), const SizedBox(height: 8), - _buildContactCard(_allContacts), + if (_isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: CircularProgressIndicator(), + ), + ) + else if (_friends.isEmpty) + _buildEmptyState() + else + _buildContactCard(_friends), ], ), ), @@ -83,36 +214,52 @@ class _ContactsScreenState extends State { return Row( children: [ Expanded( - child: GestureDetector( - onTap: () {}, - child: Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: AppColors.surfaceTertiary, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE4EBF7)), - ), - child: Row( - children: [ - const Icon(Icons.search, size: 16, color: AppColors.slate400), - const SizedBox(width: 8), - Text( - '搜索联系人', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate400, - ), - ), - ], + child: Container( + height: 40, + decoration: BoxDecoration( + color: AppColors.surfaceTertiary, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE4EBF7)), + ), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: const InputDecoration( + hintText: '输入邮箱搜索用户', + hintStyle: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate400, + ), + prefixIcon: Icon( + Icons.search, + size: 16, + color: AppColors.slate400, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), ), + style: const TextStyle(fontSize: 13), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _onSearch(), + onChanged: (value) { + if (value.isEmpty) { + setState(() { + _hasSearched = false; + _searchResults = []; + }); + } + }, ), ), ), const SizedBox(width: 10), GestureDetector( - onTap: () => context.push('/contacts/add'), + onTap: _onSearch, child: Container( width: 40, height: 40, @@ -121,17 +268,233 @@ class _ContactsScreenState extends State { borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFD7E6FF)), ), - child: const Icon( - Icons.person_add, - size: 16, - color: AppColors.blue500, - ), + child: _isSearching + ? const Padding( + padding: EdgeInsets.all(10), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.search, size: 16, color: AppColors.blue500), ), ), ], ); } + Widget _buildSearchResults() { + if (!_hasSearched) { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.only(top: 8), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + if (_isSearching) + Container( + padding: const EdgeInsets.all(20), + child: const Center(child: CircularProgressIndicator()), + ) + else if (_searchResults.isEmpty) + Container( + padding: const EdgeInsets.all(20), + child: const Center( + child: Text( + '未找到该用户', + style: TextStyle(fontSize: 14, color: AppColors.slate500), + ), + ), + ) + else + Column( + children: [ + for (int i = 0; i < _searchResults.length; i++) ...[ + _buildSearchResultItem(_searchResults[i]), + if (i < _searchResults.length - 1) + Container( + height: 1, + margin: const EdgeInsets.symmetric(horizontal: 14), + color: const Color(0xFFEEF2F7), + ), + ], + ], + ), + ], + ), + ); + } + + Widget _buildSearchResultItem(UserResponse user) { + final isFriend = _friendIds.contains(user.id); + final isSent = _sentRequestIds.contains(user.id); + final avatarColor = _getAvatarColor(user.id); + + return Container( + height: 70, + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: _getAvatarBackground(avatarColor), + borderRadius: BorderRadius.circular(21), + border: Border.all(color: _getAvatarBorder(avatarColor)), + ), + child: user.avatarUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(21), + child: Image.network( + user.avatarUrl!, + width: 42, + height: 42, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Icons.person, + size: 18, + color: _getAvatarColor(user.id), + ), + ), + ) + : Icon(Icons.person, size: 18, color: _getAvatarColor(user.id)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.username, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.slate900, + ), + ), + if (user.bio != null) ...[ + const SizedBox(height: 2), + Text( + user.bio!, + style: const TextStyle( + fontSize: 12, + color: AppColors.slate500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + _buildAddButton(user.id, isFriend, isSent), + ], + ), + ); + } + + Widget _buildAddButton(String userId, bool isFriend, bool isSent) { + if (isFriend) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.slate300, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '已是好友', + style: TextStyle(fontSize: 12, color: AppColors.slate500), + ), + ); + } + + if (isSent) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.slate300, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '已发送', + style: TextStyle(fontSize: 12, color: AppColors.slate500), + ), + ); + } + + return GestureDetector( + onTap: () { + final user = _searchResults.firstWhere((u) => u.id == userId); + _showAddFriendDialog(user); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF1F7FF), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFD7E6FF)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.person_add, size: 14, color: AppColors.blue500), + SizedBox(width: 4), + Text( + '添加', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.blue500, + ), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE3EAF6)), + ), + child: Column( + children: [ + const Icon(Icons.person_outline, size: 48, color: AppColors.slate400), + const SizedBox(height: 12), + const Text( + '暂无联系人', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + const SizedBox(height: 4), + const Text( + '搜索邮箱添加好友开始聊天吧', + style: TextStyle(fontSize: 13, color: AppColors.slate400), + ), + ], + ), + ); + } + Widget _buildSectionTitle(String title) { return Text( title, @@ -143,7 +506,7 @@ class _ContactsScreenState extends State { ); } - Widget _buildContactCard(List contacts) { + Widget _buildContactCard(List friends) { return Container( decoration: BoxDecoration( color: AppColors.white, @@ -152,9 +515,9 @@ class _ContactsScreenState extends State { ), child: Column( children: [ - for (int i = 0; i < contacts.length; i++) ...[ - _buildContactItem(contacts[i]), - if (i < contacts.length - 1) + for (int i = 0; i < friends.length; i++) ...[ + _buildContactItem(friends[i]), + if (i < friends.length - 1) Container( height: 1, margin: const EdgeInsets.symmetric(horizontal: 14), @@ -166,9 +529,12 @@ class _ContactsScreenState extends State { ); } - Widget _buildContactItem(ContactItem contact) { + Widget _buildContactItem(FriendResponse friend) { + final friendInfo = friend.friend; + final color = _getAvatarColor(friendInfo.id); + return GestureDetector( - onTap: () => context.push('/contacts/add?id=${contact.email}'), + onTap: () => context.push('/contacts/add?id=${friendInfo.id}'), child: Container( height: 70, padding: const EdgeInsets.symmetric(horizontal: 14), @@ -178,11 +544,23 @@ class _ContactsScreenState extends State { width: 42, height: 42, decoration: BoxDecoration( - color: _getAvatarBackground(contact.color), + color: _getAvatarBackground(color), borderRadius: BorderRadius.circular(21), - border: Border.all(color: _getAvatarBorder(contact.color)), + border: Border.all(color: _getAvatarBorder(color)), ), - child: Icon(Icons.person, size: 18, color: contact.color), + child: friendInfo.avatarUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(21), + child: Image.network( + friendInfo.avatarUrl!, + width: 42, + height: 42, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Icon(Icons.person, size: 18, color: color), + ), + ) + : Icon(Icons.person, size: 18, color: color), ), const SizedBox(width: 12), Expanded( @@ -191,22 +569,13 @@ class _ContactsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - contact.name, + friendInfo.username, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.slate900, ), ), - const SizedBox(height: 4), - Text( - contact.email, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), ], ), ), @@ -216,6 +585,18 @@ class _ContactsScreenState extends State { ); } + Color _getAvatarColor(String id) { + final colors = [ + AppColors.blue500, + AppColors.violet600, + AppColors.blue600, + const Color(0xFF0EA5E9), + AppColors.violet500, + ]; + final index = id.hashCode.abs() % colors.length; + return colors[index]; + } + Color _getAvatarBackground(Color color) { if (color == AppColors.blue500) return const Color(0xFFEEF4FF); if (color == AppColors.violet600) return AppColors.surfaceInfoLight; @@ -234,11 +615,3 @@ class _ContactsScreenState extends State { return const Color(0xFFDDE8FB); } } - -class ContactItem { - final String name; - final String email; - final Color color; - - ContactItem({required this.name, required this.email, required this.color}); -} diff --git a/apps/lib/features/friends/data/friends_api.dart b/apps/lib/features/friends/data/friends_api.dart new file mode 100644 index 0000000..d9d7cda --- /dev/null +++ b/apps/lib/features/friends/data/friends_api.dart @@ -0,0 +1,96 @@ +import 'package:social_app/core/api/i_api_client.dart'; + +class FriendsApi { + final IApiClient _client; + static const _prefix = '/api/v1/friends'; + + FriendsApi(this._client); + + Future> getFriends() async { + final response = await _client.get(_prefix); + final List data = response.data; + return data.map((json) => FriendResponse.fromJson(json)).toList(); + } + + Future> getIncomingRequests() async { + final response = await _client.get('$_prefix/requests/inbox'); + final List data = response.data; + return data.map((json) => FriendResponse.fromJson(json)).toList(); + } + + Future> getOutgoingRequests() async { + final response = await _client.get('$_prefix/requests/outgoing'); + final List data = response.data; + return data.map((json) => FriendResponse.fromJson(json)).toList(); + } + + Future sendRequest(String targetUserId) async { + final response = await _client.post( + '$_prefix/requests', + data: {'target_user_id': targetUserId}, + ); + return FriendResponse.fromJson(response.data); + } + + Future acceptRequest(String friendshipId) async { + final response = await _client.post( + '$_prefix/requests/$friendshipId/accept', + ); + return FriendResponse.fromJson(response.data); + } + + Future declineRequest(String friendshipId) async { + final response = await _client.post( + '$_prefix/requests/$friendshipId/decline', + ); + return FriendResponse.fromJson(response.data); + } + + Future removeFriend(String friendshipId) async { + await _client.delete('$_prefix/$friendshipId'); + } +} + +class FriendResponse { + final String id; + final UserBasicInfo friend; + final String status; + final DateTime createdAt; + final DateTime? acceptedAt; + + FriendResponse({ + required this.id, + required this.friend, + required this.status, + required this.createdAt, + this.acceptedAt, + }); + + factory FriendResponse.fromJson(Map json) { + return FriendResponse( + id: json['id'] as String, + friend: UserBasicInfo.fromJson(json['friend'] as Map), + status: json['status'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + acceptedAt: json['accepted_at'] != null + ? DateTime.parse(json['accepted_at'] as String) + : null, + ); + } +} + +class UserBasicInfo { + final String id; + final String username; + final String? avatarUrl; + + UserBasicInfo({required this.id, required this.username, this.avatarUrl}); + + factory UserBasicInfo.fromJson(Map json) { + return UserBasicInfo( + id: json['id'] as String, + username: json['username'] as String, + avatarUrl: json['avatar_url'] as String?, + ); + } +} diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index d7062ec..99fdf81 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/page_header.dart' as widgets; +import '../../../friends/data/friends_api.dart'; import '../../../users/data/models/user_response.dart'; import '../../../users/data/users_api.dart'; @@ -16,20 +17,35 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { UserResponse? _user; bool _isLoading = true; + int _friendsCount = 0; + String? _firstFriendName; @override void initState() { super.initState(); - _loadUser(); + _loadData(); } - Future _loadUser() async { + Future _loadData() async { try { final usersApi = sl(); - final user = await usersApi.getMe(); + final friendsApi = sl(); + + final results = await Future.wait([ + usersApi.getMe(), + friendsApi.getFriends(), + ]); + + final user = results[0] as UserResponse; + final friends = results[1] as List; + if (mounted) { setState(() { _user = user; + _friendsCount = friends.length; + _firstFriendName = friends.isNotEmpty + ? friends.first.friend.username + : null; _isLoading = false; }); } @@ -181,6 +197,16 @@ class _SettingsScreenState extends State { ); } + String _buildFriendsSubtitle() { + if (_friendsCount == 0) { + return '暂无联系人'; + } + if (_friendsCount == 1) { + return '已添加 1 位:$_firstFriendName'; + } + return '已添加 $_friendsCount 位联系人'; + } + Widget _buildQuickActions(BuildContext context) { return Container( height: 120, @@ -199,7 +225,7 @@ class _SettingsScreenState extends State { iconBg: AppColors.surfaceTertiary, iconBorder: const Color(0xFFE6ECF7), title: '联系人', - subtitle: '已添加 1 位:Toki', + subtitle: _buildFriendsSubtitle(), onTap: () => context.push('/contacts'), ), ), diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bbe55d6d2b250e1e4dfcce8faef0e74dc6cffd0e GIT binary patch literal 8196 zcmeHM&rcIU6n;}swjjFv6r&~^doh6^fWH2}#d- z^Wx2me}IW6kNOAb(W?jl0WY5Q&5t52aPVR@&XAe!?VI<_%=?)-Ko>G%s6h$;3dnV_Ztz=w6YFgYD+@Q^Hr#@{um}#0P=R7IgIs7~ zbl}J#uBrka1mL$szi5f>gc;1jhfR@32^q+3=4AM5V}B-XkYFY)BWL2nqrB~;s#JOB zRl^`j$HqRBOk4Z0;~hqa(PgYT8*JN2-L#hE-FSsREix8*4cqnZ1$4V$_TOYt>IM;w z1tBO>3|YM&L`AlpXSJx92(BYLjI5C@m_s`|b$o2Zx-v1fyPGuz za+j_x6gI<3#Gc{Gu@Lo|gl_q~*FHx}^jkX@|4-r+t<_I zGcYhPczWne@7Z$&v(Iz=&2r4wd6R`K&WANxFi$0z?X1tD`Iv5DmP;vpoI1f%?CNbH zEYRYEU#`U`RgCHX7?K^m}Vc~#7w(#mX9 zVmKpT!CaMhCHH8YARgc>ExU2-MrE3RvKG1?4dUzqHSR)=hzDC T648T2{}3Q#(2Z8$k1FsJg8d^c literal 0 HcmV?d00001 diff --git a/backend/AGENTS.md b/backend/AGENTS.md index df8539c..2513c7c 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -231,21 +231,23 @@ class AgentType(str, Enum): Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance. -## Multi-Agent Orchestration (CrewAI Framework) +## Multi-Agent Orchestration (AgentScope Framework) -Multi-agent orchestration MUST use the CrewAI framework. **Use the `crewai` skill** for framework reference and implementation guidance. +Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentscope-skill`** for framework reference and implementation guidance. + +For workflows involving routing, LiteLLM proxy cost audit, or frontend/backend human approval loops, **use the `agentscope-hitl-cost` skill**. ### Core Principles -- Use CrewAI for orchestrating multiple agents working together -- Define clear agent roles, tasks, and crews -- Leverage built-in collaboration and delegation mechanisms -- Follow CrewAI best practices for agent configuration +- Use AgentScope for orchestrating multiple agents working together +- Define clear agent roles, stage responsibilities, and pipeline boundaries +- Leverage AgentScope built-in workflow and tool middleware mechanisms +- Follow AgentScope best practices for agent configuration ### Key Components - **Agents**: Autonomous units with specific roles and goals -- **Tasks**: Assignments that agents complete -- **Crews**: Teams of agents working together +- **Tasks**: Stage-specific prompts and execution goals +- **Pipelines**: Ordered orchestration flow between agents - **Tools**: Capabilities available to agents - **Flows**: Workflow orchestration and state management diff --git a/backend/scripts/build_litellm_proxy_config.py b/backend/scripts/build_litellm_proxy_config.py new file mode 100644 index 0000000..55a75fa --- /dev/null +++ b/backend/scripts/build_litellm_proxy_config.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +import yaml + +from core.config.initial.init_data import load_llm_catalog + + +def _provider_key_env_name(factory_name: str) -> str: + normalized = factory_name.strip().upper() + if normalized == "VOLCENGINE": + normalized = "ARK" + return f"SOCIAL_LLM__PROVIDER_KEYS__{normalized}" + + +def build_proxy_config() -> dict[str, Any]: + catalog = load_llm_catalog() + + factories = catalog.get("factories", []) + llms = catalog.get("llms", []) + if not isinstance(factories, list) or not isinstance(llms, list): + raise ValueError("invalid llm catalog format") + + factory_url_map: dict[str, str] = {} + for factory in factories: + if not isinstance(factory, dict): + continue + name = str(factory.get("name", "")).strip().lower() + request_url = str(factory.get("request_url", "")).strip() + if name and request_url: + factory_url_map[name] = request_url + + model_list: list[dict[str, Any]] = [] + for llm in llms: + if not isinstance(llm, dict): + continue + model_code = str(llm.get("model_code", "")).strip() + factory_name = str(llm.get("factory_name", "")).strip() + litellm_model = str(llm.get("litellm_model", "")).strip() + if not model_code or not factory_name or not litellm_model: + continue + + api_base = factory_url_map.get(factory_name.lower()) + if not api_base: + raise ValueError( + f"factory request_url missing for model {model_code}: {factory_name}" + ) + + env_key_name = _provider_key_env_name(factory_name) + provider_model = ( + litellm_model.split("/", 1)[1] if "/" in litellm_model else litellm_model + ) + + model_list.append( + { + "model_name": model_code, + "litellm_params": { + "model": f"openai/{provider_model}", + "api_base": api_base, + "api_key": f"os.environ/{env_key_name}", + }, + } + ) + + if not model_list: + raise ValueError("no models found in llm catalog") + + return {"model_list": model_list} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build LiteLLM proxy config") + parser.add_argument("--output", required=True, help="Output YAML file path") + args = parser.parse_args() + + output_path = Path(args.output).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + config = build_proxy_config() + with output_path.open("w", encoding="utf-8") as file: + yaml.safe_dump(config, file, sort_keys=False, allow_unicode=False) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/.DS_Store b/backend/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..11461c486fe0d852558d95e0c6af6fea57809491 GIT binary patch literal 8196 zcmeHMO-~a+7=EWBY^lhCfHi8ep%)Vx#Sb*bgGpoEUZj1N(k0!91o;2~Bv8W7MX2wKHU&ljqkvJsC}0#Y3j7NS;GQkaI%MBh zwI(+T7zO@I1;q2g!6eYt*ia}R9hk`@0AdFgOG6*e0g~frbTu{EgCD^qQPvbwB{^|1JuuNM<9S&a)`v?#7-IS>7&@A-29 zT`$=K6PzVpz-S~1L4{(-(u06i_qpo-ISLp4`J1DQ|`H(No`FywS=8V$a znG-!Hd+omdQ+)${gM))Zr?Y4J&z>vUr+lycv>J(#PVkUNg>aRY?A{pXJ6YyzI-+Yh z&-sKtPl~|{tmrp_7RW~LMU=9|Y7|taR#sw~%2q^Cq9y?P(w}Iy*Oc ztr)S8vnrN}_)e(dm%f6FDTKK7i=PsE-rgl>L5y(`tl`2fqt_lEwZ*3}@^WjNa#_FK zv#gQQG%*|plR#HvL!l@Vyi-7y+|>_Yi(THqw2VoZh-)a66wF$F2;lY3ABN~V7%Dif#)d*HK{GD`qzooA J3j9?CegoQ%B2EAR literal 0 HcmV?d00001 diff --git a/backend/src/core/.DS_Store b/backend/src/core/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..463325ad93cf50aaffc453395d1d339c6f12ec94 GIT binary patch literal 8196 zcmeHMO;6N77=CA2p(~>0Q;eE4?!^Rx2%<3_Tvv@D9w01H2Z*Ir@<})66`xGtWC8Py4ogy8u9njp{{!VF0i& z3$(kj_(c-^A|FT@-O__d&>p~t7|M8zLHz*RG#CYp0!9I&fKk9G@GmHU&uo#bL!SGp z*OW#9qriWufVe+6m<1LzHWkWS2Np5}K+IyZOjyS_K-xGO3mTgWr74c7q6eZ)i4HM{ zGRJ;L&=Ct7n+laV5M>TT&rEcLLgegd=SVt`f_7!v%xgf!I4P?)DXSyMts#>^oMe)7DP|U#4g8+4mf}56 z@j7vCj^nN3>T5}EM%*OY*PP&b+fiNb@2yu4{3sqD|3oq!*(04@R+rUnJt(d5?NaQ< zji~5`OXAl&=YiL%yWTyYZkO$Wo1De2&uAzO{3=Dt@_nCG`F4>vST&NiCDmc&tX$b1 z-rb$JGBxTcY)H3ouM07K9GCnj*wvRZn4{H)Kbz)v>c|=R*&qkK6H>~SYN219 zSzV22ywFx(6th)A9t5*I@?)Q~#br5rOgE<4D#sbc3TEn}E4oL+2=Rc;!lE07F00Yv z&V#`9XfVTyJdXLsf{(ipE0

Tbnnwfyz3O3hrpa9-7H$hQQx23j?ZB>&D^k@b1T z8AYR6)srvW)f5Xx;0Ban88)E_FW?othWGFhzQ8y5PI|}?IY&mwD48NxNP*lSb7YCE z5SKhs_L3lXd%do#rqj=j${&wo_IB%{D@ zRG>qRJuKq?+lPPu|BWj!LmLH*0)JZplD$>BC0_4aZy)IeiPvZc^9p8>1h1)3nqVO) iqFsjLi2HvSV%^bw1&vLG*n`P@2#_+E!YJ@Z75D*S!AL>? literal 0 HcmV?d00001 diff --git a/backend/src/core/config/.DS_Store b/backend/src/core/config/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8890d0b0d5cb346fa811badc8f5ff63dd7877266 GIT binary patch literal 6148 zcmeHKPfrs;6n|4HY^lfsEn?JULoX&Yh#(N-!BQ}Wcz{rXC4gnOUCPQbQ+K!2N=SOv zn-@QTAHc+uNBsbL^ybgc2LKHl z$9O+>zYuQctxIBRh8u}k!+`)w_?v`Umv=M~RUoRs-&O(s?k2@M>i~uG?{9P2Wo64{ z7JBgGA&+hHR0wJzs|HOdgNbb)Y`#th>s<${4`awU4zk>KZ!+HDodh3O4e-ZVRUGk? zrDJF0RR^mV{Q_K_v&!ueV*JcHpGBLm)$nU7I;eW#4q&rX?c>CDZ$Yo!DCkg^xJe-wh}t&jnQA-qJT zEhndZq;evk!koV9dYDzBKY4NB(x5&xJTjCTN~hDKm&dLQU%gh+M=Y~^Q1$q37n#ev zyj!ygjn=}XWMkE>N%B_ zy*=L!is$rA9Q3uFyOS0(Mw*FblqO5wcJ-%MPt(?tPb)Ok{F5q^3hAEUuOcHH9Se!P*u2I%U*G z(yFv%au1b@1{1IV1=xnC(1O?S2HwI)_zd6R2b_`tGD^nD1j&+FGEZ`3fvk}&vP(?z zMDk#!J{stRh$nw?Ub&Oa?_GsmS`fXKvaSMy#oAvFtBmVDQrm; zM+bKNI_35_M=q%IKMNcUg*k;SiD*F~l8Pv)M15iqNym9Z`Q;S0BuY9E5gFG}k%{_3 zAtE}?8^Rq(PGZzY6^JU(UV$Dt)cO5?|M%bj?WAZFRUoRsKUDz|Un(r+F(q}kb|uH} xS_9iIHZI(+B~ehYQ`fOj_*FcHO$gcyt{~wt4Cep< literal 0 HcmV?d00001 diff --git a/backend/src/core/config/initial/init_data.py b/backend/src/core/config/initial/init_data.py index c615483..e5cf6fc 100644 --- a/backend/src/core/config/initial/init_data.py +++ b/backend/src/core/config/initial/init_data.py @@ -28,6 +28,8 @@ class LlmFactorySeed(BaseModel): class LlmSeed(BaseModel): model_code: str factory_name: str + litellm_model: str + pricing_tiers: list[dict[str, float | int]] class LlmCatalogSeed(BaseModel): diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 22b1959..77180a3 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -170,6 +170,17 @@ class LlmSettings(BaseModel): provider_keys: dict[str, str] = Field(default_factory=dict) +class LiteLLMSettings(BaseModel): + host: str = "127.0.0.1" + port: int = 3875 + api_key: str = "sk-local" + + @computed_field + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}/v1" + + class DatabaseSettings(BaseModel): host: str = "localhost" port: int = 5432 @@ -206,6 +217,7 @@ class Settings(BaseSettings): supabase: SupabaseSettings = Field() storage: StorageSettings = StorageSettings() llm: LlmSettings = LlmSettings() + litellm: LiteLLMSettings = LiteLLMSettings() agent_runtime: AgentRuntimeSettings = AgentRuntimeSettings() taskiq: TaskiqSettings = TaskiqSettings() database: DatabaseSettings = DatabaseSettings() diff --git a/backend/src/core/config/static/.DS_Store b/backend/src/core/config/static/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..748e82e75f2ca2aa4592507e6ff2dff3be1be003 GIT binary patch literal 6148 zcmeHLL2uJA6n^dsb2TCL0Md?@xK_|@YzTGQ^kPX@BHMeK8-TV73wRf;T zZ>iaX-F8de+dG)g8}ioeyN6GE@3LX8KbVn{z)~iYTE`jc zZr6=netY>zJ^XU^Xgx3aBi`ub_kF$($9bmnWAw8a&(b6Ms#m}(a0L}`Yk}Olf@AkX z_6m3fE?0op2L)#g999<1(t*Mp0f2RcjiJt;jGUt!1`aEWXn_fv3bd)h9WjJWM_f5x z;IOi2(@B_~aUa*Sa3>UDdPiItI*GudFTDa@fr|=k*vlsG|4)8>{=b;yue<_Yf&WSY zQSXLb{*%Y;tt*4$y;i~B!r7QtS^SxTirI?6<*j%dZVYk76=2}7vWN)Ge+U>EeBl-N Hs|x%82K|Y^ literal 0 HcmV?d00001 diff --git a/backend/src/core/config/static/database/llm_catalog.yaml b/backend/src/core/config/static/database/llm_catalog.yaml index 086cd91..8475fd5 100644 --- a/backend/src/core/config/static/database/llm_catalog.yaml +++ b/backend/src/core/config/static/database/llm_catalog.yaml @@ -1,34 +1,52 @@ factories: - - name: dashscope - request_url: https://dashscope.aliyuncs.com/compatible-mode/v1 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/qwen-color.png + - name: dashscope + request_url: https://dashscope.aliyuncs.com/compatible-mode/v1 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/qwen-color.png - - name: minimax - request_url: https://api.minimaxi.com/v1 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/minimax-color.png + - name: minimax + request_url: https://api.minimaxi.com/v1 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/minimax-color.png - - name: moonshot - request_url: https://api.moonshot.cn/v1 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/moonshot.png + - name: moonshot + request_url: https://api.moonshot.cn/v1 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/moonshot.png - - name: deepseek - request_url: https://api.deepseek.com/v1 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/deepseek-color.png + - name: deepseek + request_url: https://api.deepseek.com/v1 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/deepseek-color.png - - name: volcengine - request_url: https://ark.cn-beijing.volces.com/api/v3 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/doubao-color.png + - name: volcengine + request_url: https://ark.cn-beijing.volces.com/api/v3 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/doubao-color.png - - name: zai - request_url: https://api.z.ai/api/paas/v4 - avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/zai.png + - name: zai + request_url: https://api.z.ai/api/paas/v4 + avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/zai.png llms: - # 你原来的两个保留 - - model_code: qwen3.5-flash - factory_name: dashscope - litellm_model: dashscope/qwen-turbo + # qwen3.5-flash (3 tiers: 128K, 256K, 1M) + - model_code: qwen3.5-flash + factory_name: dashscope + litellm_model: dashscope/qwen3.5-flash + pricing_tiers: + - max_prompt_tokens: 128000 + input_cost_per_token: 0.0000002 + output_cost_per_token: 0.000002 + cache_hit_cost_per_token: 0.00000002 + - max_prompt_tokens: 256000 + input_cost_per_token: 0.0000008 + output_cost_per_token: 0.000008 + cache_hit_cost_per_token: 0.00000008 + - max_prompt_tokens: 1000000 + input_cost_per_token: 0.0000012 + output_cost_per_token: 0.000012 + cache_hit_cost_per_token: 0.00000012 - - model_code: deepseek-chat - factory_name: deepseek - litellm_model: deepseek/deepseek-chat + - model_code: deepseek-chat + factory_name: deepseek + litellm_model: deepseek/deepseek-chat + pricing_tiers: + - max_prompt_tokens: 128000 + input_cost_per_token: 0.000002 + output_cost_per_token: 0.000003 + cache_hit_cost_per_token: 0.0000002 diff --git a/backend/src/services/litellm/__init__.py b/backend/src/services/litellm/__init__.py new file mode 100644 index 0000000..83c508e --- /dev/null +++ b/backend/src/services/litellm/__init__.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from services.litellm.service import ( + LiteLLMResponseWithCost, + LiteLLMService, + LiteLLMUsage, +) + +__all__ = ["LiteLLMService", "LiteLLMUsage", "LiteLLMResponseWithCost"] diff --git a/backend/src/services/litellm/service.py b/backend/src/services/litellm/service.py new file mode 100644 index 0000000..e2f8ddb --- /dev/null +++ b/backend/src/services/litellm/service.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from litellm import completion + +from core.config.settings import config +from core.config.initial.init_data import load_llm_catalog + + +@dataclass(frozen=True) +class PricingTier: + max_prompt_tokens: int + input_cost_per_token: float + output_cost_per_token: float + cache_hit_cost_per_token: float + + +@dataclass(frozen=True) +class LiteLLMUsage: + prompt_tokens: int + completion_tokens: int + total_tokens: int + cached_prompt_tokens: int + cost: float + + +@dataclass(frozen=True) +class LiteLLMResponseWithCost: + response: dict[str, Any] + usage: LiteLLMUsage + + +class LiteLLMService: + proxy_base_url: str + proxy_api_key: str + _pricing_by_model: dict[str, tuple[PricingTier, ...]] + + def __init__( + self, + *, + proxy_base_url: str | None = None, + proxy_api_key: str | None = None, + ) -> None: + self.proxy_base_url = proxy_base_url or config.litellm.base_url + self.proxy_api_key = proxy_api_key or config.litellm.api_key + self._pricing_by_model = self._build_pricing_map() + + @staticmethod + def _build_pricing_map() -> dict[str, tuple[PricingTier, ...]]: + catalog = load_llm_catalog() + pricing_by_model: dict[str, tuple[PricingTier, ...]] = {} + for model in catalog.get("llms", []): + if not isinstance(model, dict): + continue + model_code = str(model.get("model_code", "")).strip().lower() + litellm_model = str(model.get("litellm_model", "")).strip().lower() + raw_tiers = model.get("pricing_tiers") + if not isinstance(raw_tiers, list) or not raw_tiers: + continue + + tiers = [ + PricingTier( + max_prompt_tokens=int(item.get("max_prompt_tokens", 0) or 0), + input_cost_per_token=float( + item.get("input_cost_per_token", 0.0) or 0.0 + ), + output_cost_per_token=float( + item.get("output_cost_per_token", 0.0) or 0.0 + ), + cache_hit_cost_per_token=float( + item.get("cache_hit_cost_per_token", 0.0) or 0.0 + ), + ) + for item in raw_tiers + if isinstance(item, dict) + ] + if not tiers: + continue + ordered_tiers = tuple( + sorted(tiers, key=lambda item: item.max_prompt_tokens) + ) + if model_code: + pricing_by_model[model_code] = ordered_tiers + if litellm_model: + pricing_by_model[litellm_model] = ordered_tiers + return pricing_by_model + + def calculate_cost( + self, + *, + model: str, + prompt_tokens: int, + completion_tokens: int, + cached_prompt_tokens: int = 0, + ) -> float: + tiers = self._pricing_by_model.get(model.strip().lower()) + if tiers is None: + raise ValueError(f"unknown model pricing: {model}") + + normalized_prompt_tokens = max(int(prompt_tokens), 0) + normalized_completion_tokens = max(int(completion_tokens), 0) + normalized_cached_tokens = min( + max(int(cached_prompt_tokens), 0), normalized_prompt_tokens + ) + uncached_prompt_tokens = normalized_prompt_tokens - normalized_cached_tokens + + selected_tier = tiers[-1] + for tier in tiers: + if normalized_prompt_tokens <= tier.max_prompt_tokens: + selected_tier = tier + break + + return float( + uncached_prompt_tokens * selected_tier.input_cost_per_token + + normalized_cached_tokens * selected_tier.cache_hit_cost_per_token + + normalized_completion_tokens * selected_tier.output_cost_per_token + ) + + def run_completion_with_cost( + self, + *, + model: str, + messages: list[dict[str, Any]], + temperature: float | None = None, + max_tokens: int | None = None, + timeout: float | None = None, + completion_fn: Callable[..., dict[str, Any]] | None = None, + ) -> LiteLLMResponseWithCost: + caller = completion_fn or completion + request_model = model if model.startswith("openai/") else f"openai/{model}" + + response_any = caller( + model=request_model, + api_key=self.proxy_api_key, + api_base=self.proxy_base_url, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + stream=False, + ) + response = self._normalize_response(response_any) + + usage_raw = response.get("usage") + if not isinstance(usage_raw, dict): + raise ValueError("missing usage in response") + + prompt_tokens = int(usage_raw.get("prompt_tokens", 0) or 0) + completion_tokens = int(usage_raw.get("completion_tokens", 0) or 0) + total_tokens = int( + usage_raw.get("total_tokens", prompt_tokens + completion_tokens) or 0 + ) + cached_prompt_tokens = 0 + prompt_tokens_details = usage_raw.get("prompt_tokens_details") + if isinstance(prompt_tokens_details, dict): + cached_prompt_tokens = int( + prompt_tokens_details.get("cached_tokens", 0) or 0 + ) + + resolved_model = str(response.get("model", model)).strip() + cost = self.calculate_cost( + model=resolved_model, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + cached_prompt_tokens=cached_prompt_tokens, + ) + return LiteLLMResponseWithCost( + response=response, + usage=LiteLLMUsage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + cached_prompt_tokens=cached_prompt_tokens, + cost=cost, + ), + ) + + @staticmethod + def _normalize_response(response_any: Any) -> dict[str, Any]: + if isinstance(response_any, dict): + return response_any + model_dump = getattr(response_any, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict): + return dumped + raise ValueError("litellm response is not serializable") diff --git a/backend/tests/unit/core/agent/test_init_data.py b/backend/tests/unit/core/agent/test_init_data.py index fea5c8b..0e19311 100644 --- a/backend/tests/unit/core/agent/test_init_data.py +++ b/backend/tests/unit/core/agent/test_init_data.py @@ -31,3 +31,26 @@ def test_seed_data_does_not_keep_legacy_deepseek_alias() -> None: catalog = load_llm_catalog() assert all(entry["model_code"] != "deepseek-v3.2" for entry in catalog["llms"]) + + +def test_llm_catalog_contains_litellm_routing_and_pricing_fields() -> None: + catalog = load_llm_catalog() + + for entry in catalog["llms"]: + assert set(entry.keys()) == { + "model_code", + "factory_name", + "litellm_model", + "pricing_tiers", + } + assert isinstance(entry["litellm_model"], str) + assert "/" in entry["litellm_model"] + pricing_tiers = entry["pricing_tiers"] + assert isinstance(pricing_tiers, list) + assert len(pricing_tiers) > 0 + for tier in pricing_tiers: + assert isinstance(tier, dict) + assert int(tier["max_prompt_tokens"]) > 0 + assert float(tier["input_cost_per_token"]) >= 0 + assert float(tier["output_cost_per_token"]) >= 0 + assert float(tier["cache_hit_cost_per_token"]) >= 0 diff --git a/backend/tests/unit/services/test_litellm_service.py b/backend/tests/unit/services/test_litellm_service.py new file mode 100644 index 0000000..5976f82 --- /dev/null +++ b/backend/tests/unit/services/test_litellm_service.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import pytest + +from services.litellm.service import LiteLLMService + + +def test_calculate_cost_uses_first_qwen_tier() -> None: + service = LiteLLMService() + + cost = service.calculate_cost( + model="dashscope/qwen3.5-flash", + prompt_tokens=100_000, + completion_tokens=1_000, + cached_prompt_tokens=10_000, + ) + + assert cost == pytest.approx(0.0202) + + +def test_calculate_cost_uses_second_qwen_tier() -> None: + service = LiteLLMService() + + cost = service.calculate_cost( + model="dashscope/qwen3.5-flash", + prompt_tokens=200_000, + completion_tokens=5_000, + cached_prompt_tokens=20_000, + ) + + assert cost == pytest.approx(0.1856) + + +def test_run_completion_extracts_usage_and_cost() -> None: + service = LiteLLMService() + + result = service.run_completion_with_cost( + model="dashscope/qwen3.5-flash", + messages=[{"role": "user", "content": "hello"}], + completion_fn=lambda **_: { + "model": "dashscope/qwen3.5-flash", + "usage": { + "prompt_tokens": 2000, + "completion_tokens": 100, + "total_tokens": 2100, + "prompt_tokens_details": {"cached_tokens": 500}, + }, + "choices": [{"message": {"content": "ok"}}], + }, + ) + + assert result.usage.prompt_tokens == 2000 + assert result.usage.completion_tokens == 100 + assert result.usage.total_tokens == 2100 + assert result.usage.cost == pytest.approx(0.00051) diff --git a/infra/mail-templates/confirmation.html b/infra/mail-templates/confirmation.html deleted file mode 100644 index 5af7bb0..0000000 --- a/infra/mail-templates/confirmation.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - 确认邮箱 - - - - - - -
-

请确认你的邮箱

-

你好,{{ .Email }}:

-

请输入以下 6 位验证码完成注册:

-

{{ .Token }}

-

验证码有效期较短,请尽快完成验证。

-
- - diff --git a/infra/mail-templates/recovery.html b/infra/mail-templates/recovery.html deleted file mode 100644 index ce4d0a4..0000000 --- a/infra/mail-templates/recovery.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - 重置密码 - - - - - - -
-

重置你的账户密码

-

你好,{{ .Email }}:

-

如果你使用验证码方式,请输入以下 6 位验证码:

-

{{ .Token }}

-

验证码有效期较短,请尽快完成重置流程。

-
- - diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index 96a65d5..ae49710 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -5,12 +5,13 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" SESSION_NAME="${SESSION_NAME:-social-dev}" COMPOSE_FILE="$ROOT_DIR/infra/docker/docker-compose.yml" ENV_FILE="$ROOT_DIR/.env" +LITELLM_RUNTIME_CONFIG="$ROOT_DIR/.tmp/litellm-proxy-config.yaml" usage() { echo "Usage: $0 {start|stop|restart}" echo "" echo "Commands:" - echo " start Start web + worker processes in tmux" + echo " start Start LiteLLM + web + worker processes in tmux" echo " stop Stop tmux session and clean orphaned processes" echo " restart Stop then start all app processes" exit 1 @@ -86,9 +87,37 @@ kill_pids_gracefully() { kill -KILL "${alive[@]}" 2>/dev/null || true } +kill_matching_processes() { + local label="$1" + local pattern="$2" + local pids + + pids="$(pgrep -f "$pattern" || true)" + if [ -z "$pids" ]; then + return + fi + + # shellcheck disable=SC2086 + kill_pids_gracefully "$label" $pids +} + +kill_listening_processes() { + local label="$1" + local port="$2" + local pids + + pids="$(collect_listening_pids "$port" || true)" + if [ -z "$pids" ]; then + return + fi + + # shellcheck disable=SC2086 + kill_pids_gracefully "$label" $pids +} + start() { echo "=== App Up ===" - echo "This script starts web + worker processes in tmux." + echo "This script starts LiteLLM + web + worker processes in tmux." echo "NOTE: Bootstrap (migrate + init-data) must be run separately." echo "" @@ -110,8 +139,9 @@ start() { load_env_if_exists UVICORN_LOG_LEVEL="${SOCIAL_RUNTIME__LOG_LEVEL:-info}" - UVICORN_LOG_LEVEL="${UVICORN_LOG_LEVEL,,}" + UVICORN_LOG_LEVEL="$(echo "$UVICORN_LOG_LEVEL" | tr '[:upper:]' '[:lower:]')" WEB_PORT="${SOCIAL_WEB__PORT:-5775}" + LITELLM_PORT="${SOCIAL_LITELLM__PORT:-3875}" if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then echo "Error: tmux session '$SESSION_NAME' already exists." >&2 @@ -125,7 +155,24 @@ start() { exit 1 fi - echo "Starting web + worker processes in tmux session '$SESSION_NAME'..." + if is_port_in_use "$LITELLM_PORT"; then + echo "Error: litellm port ${LITELLM_PORT} is already in use." >&2 + echo "Hint: run '$0 stop' or change SOCIAL_LITELLM__PORT in .env" >&2 + exit 1 + fi + + if [ -z "${SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE:-}" ]; then + echo "Warning: SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE is empty; qwen calls may fail." >&2 + fi + if [ -z "${SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK:-}" ]; then + echo "Warning: SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK is empty; deepseek calls may fail." >&2 + fi + + echo "Starting LiteLLM + web + worker processes in tmux session '$SESSION_NAME'..." + + PYTHONPATH=backend/src uv run python backend/scripts/build_litellm_proxy_config.py --output "$LITELLM_RUNTIME_CONFIG" + + LITELLM_CMD="cd '$ROOT_DIR' && DASHSCOPE_API_KEY='${SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE:-}' DEEPSEEK_API_KEY='${SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK:-}' ARK_API_KEY='${SOCIAL_LLM__PROVIDER_KEYS__ARK:-}' uv run litellm --config '$LITELLM_RUNTIME_CONFIG' --port ${LITELLM_PORT}" WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=web uv run uvicorn app:app --host \ ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers \ @@ -135,7 +182,8 @@ ${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" WORKER_DEFAULT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-default uv run taskiq worker core.taskiq.app:default_broker core.agent.infrastructure.queue.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}" WORKER_BULK_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk uv run taskiq worker core.taskiq.app:bulk_broker core.agent.infrastructure.queue.tasks --workers ${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}" - tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" + tmux new-session -d -s "$SESSION_NAME" -n litellm "bash -lc \"$LITELLM_CMD; echo '[litellm] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" tmux new-window -t "$SESSION_NAME" -n worker-critical "bash -lc \"$WORKER_CRITICAL_CMD; echo '[worker-critical] exited'; exec bash\"" tmux new-window -t "$SESSION_NAME" -n worker-default "bash -lc \"$WORKER_DEFAULT_CMD; echo '[worker-default] exited'; exec bash\"" tmux new-window -t "$SESSION_NAME" -n worker-bulk "bash -lc \"$WORKER_BULK_CMD; echo '[worker-bulk] exited'; exec bash\"" @@ -143,6 +191,7 @@ ${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" echo "" echo "=== App Started ===" echo "Log files will be created in logs/ directory:" + echo " - litellm.log, litellm.error.log" echo " - web.log, web.error.log" echo " - worker-critical.log, worker-critical.error.log" echo " - worker-default.log, worker-default.error.log" @@ -156,6 +205,7 @@ stop() { echo "=== App Down ===" load_env_if_exists WEB_PORT="${SOCIAL_WEB__PORT:-5775}" + LITELLM_PORT="${SOCIAL_LITELLM__PORT:-3875}" if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then echo "Stopping tmux session '$SESSION_NAME'..." @@ -166,14 +216,12 @@ stop() { echo "Checking for orphaned processes..." - mapfile -t uvicorn_pids < <(pgrep -f "uv run uvicorn app:app" || true) - kill_pids_gracefully "uvicorn" "${uvicorn_pids[@]}" + kill_matching_processes "uvicorn" "uv run uvicorn app:app" + kill_matching_processes "litellm" "uv run litellm --config" + kill_matching_processes "taskiq workers" "uv run taskiq worker core.taskiq.app:" - mapfile -t taskiq_pids < <(pgrep -f "uv run taskiq worker core.taskiq.app:" || true) - kill_pids_gracefully "taskiq workers" "${taskiq_pids[@]}" - - mapfile -t port_pids < <(collect_listening_pids "$WEB_PORT" || true) - kill_pids_gracefully "port ${WEB_PORT} listeners" "${port_pids[@]}" + kill_listening_processes "port ${WEB_PORT} listeners" "$WEB_PORT" + kill_listening_processes "port ${LITELLM_PORT} listeners" "$LITELLM_PORT" if is_port_in_use "$WEB_PORT"; then echo "Warning: port ${WEB_PORT} is still in use after cleanup." >&2 @@ -181,6 +229,12 @@ stop() { return 1 fi + if is_port_in_use "$LITELLM_PORT"; then + echo "Warning: port ${LITELLM_PORT} is still in use after cleanup." >&2 + echo "Hint: check process with 'lsof -iTCP:${LITELLM_PORT} -sTCP:LISTEN'" >&2 + return 1 + fi + echo "Session stopped and cleaned up." } diff --git a/pyproject.toml b/pyproject.toml index b69117a..166e8cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "crewai-tools>=1.6.1", "email-validator>=2.3.0", "fastapi>=0.128.0", - "litellm>=1.52.0", + "litellm[proxy]>=1.52.0", "playwright>=1.57.0", "pydantic>=2.11.0", "pydantic-settings>=2.10.0",