diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..43dabe1 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example index 3294cda..c6a746a 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ SOCIAL_WEB__HOST=0.0.0.0 SOCIAL_WEB__PORT=5775 SOCIAL_WEB__WORKERS=2 +############ +# LiteLLM Proxy 网关配置 +############ +SOCIAL_LITELLM__PORT=3875 + ############ # Redis 配置 ############ diff --git a/.gitignore b/.gitignore index 9331cec..ab46430 100644 --- a/.gitignore +++ b/.gitignore @@ -178,8 +178,7 @@ __marimo__/ # Flutter/Dart/Pub related **/doc/api/ .dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies +.dart_tool/flutter_build/ **/generated_plugin_registrant.dart .packages .pub-preload-cache/ @@ -190,6 +189,9 @@ linked_*.ds unlinked.ds unlinked_spec.ds +# IDE +.idea/ + # Android related **/android/**/gradle-wrapper.jar .gradle/ @@ -200,6 +202,7 @@ unlinked_spec.ds **/android/**/GeneratedPluginRegistrant.java **/android/key.properties *.jks +**/android/**/*.iml # iOS/XCode related **/ios/**/*.mode1v3 @@ -230,6 +233,9 @@ unlinked_spec.ds **/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* +**/ios/Podfile.lock +**/ios/Runner.xcodeproj/ +**/ios/Runner.xcworkspace/ # macOS **/Flutter/ephemeral/ diff --git a/.tmp/litellm-proxy-config.yaml b/.tmp/litellm-proxy-config.yaml new file mode 100644 index 0000000..68b93e1 --- /dev/null +++ b/.tmp/litellm-proxy-config.yaml @@ -0,0 +1,11 @@ +model_list: +- model_name: qwen3.5-flash + litellm_params: + model: openai/qwen3.5-flash + api_base: https://dashscope.aliyuncs.com/compatible-mode/v1 + api_key: os.environ/SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE +- model_name: deepseek-chat + litellm_params: + model: openai/deepseek-chat + api_base: https://api.deepseek.com/v1 + api_key: os.environ/SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK diff --git a/AGENTS.md b/AGENTS.md index 07e7112..3badf22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Follow this hierarchy when developing: | Skill | Purpose | When to Use | |-------|---------|-------------| | **ag-ui** | AG-UI protocol for agent-user interaction | Agent chat, streaming events, tool calls, state sync | -| **crewai** | CrewAI framework for multi-agent orchestration | Multi-agent systems, agent collaboration, task automation | +| **agentscope-skill** | AgentScope framework reference and examples | AgentScope multi-agent orchestration, API usage, implementation patterns | **Usage**: Reference skills by name (e.g., "use the `ag-ui` skill") in development rules. Skills provide complete documentation, examples, and best practices. diff --git a/apps/ios/Runner.xcodeproj/project.pbxproj b/apps/ios/Runner.xcodeproj/project.pbxproj index 08d2df0..bfe57bf 100644 --- a/apps/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 09639293B873EB105430001E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1ACBA06AC19BA16E8A19E32E /* Pods_Runner.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; @@ -14,6 +15,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + AF132A70E83FBE1B638C6F9F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,12 +44,16 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1ACBA06AC19BA16E8A19E32E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2D3506D6151CBE9FA501D0C6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4B05AE96C27BBD0C62C10BF8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,6 +61,10 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 990B7AE4995B1DA3D21F475A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 9B0AA8E2CDF83E1EE2B9D8B0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +72,15 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 09639293B873EB105430001E /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 98F37246806251D350F291F4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AF132A70E83FBE1B638C6F9F /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +95,29 @@ path = RunnerTests; sourceTree = ""; }; + 4F9D784F5CA1FA42C9C1C04D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1ACBA06AC19BA16E8A19E32E /* Pods_Runner.framework */, + 90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 72B87A4C26233A1630FEF9DB /* Pods */ = { + isa = PBXGroup; + children = ( + 9B0AA8E2CDF83E1EE2B9D8B0 /* Pods-Runner.debug.xcconfig */, + 2D3506D6151CBE9FA501D0C6 /* Pods-Runner.release.xcconfig */, + 4B05AE96C27BBD0C62C10BF8 /* Pods-Runner.profile.xcconfig */, + 990B7AE4995B1DA3D21F475A /* Pods-RunnerTests.debug.xcconfig */, + DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */, + E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +136,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 72B87A4C26233A1630FEF9DB /* Pods */, + 4F9D784F5CA1FA42C9C1C04D /* Frameworks */, ); sourceTree = ""; }; @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 6CA8EFA5842F4100A4769160 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 98F37246806251D350F291F4 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + C6D974DBE54952499F275A1B /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + A5E6C420AAAF89A8795B6C9E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -238,6 +286,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 6CA8EFA5842F4100A4769160 /* [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-RunnerTests-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; + }; 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 0000000..bbe55d6 Binary files /dev/null and b/backend/.DS_Store differ 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 0000000..11461c4 Binary files /dev/null and b/backend/src/.DS_Store differ diff --git a/backend/src/core/.DS_Store b/backend/src/core/.DS_Store new file mode 100644 index 0000000..463325a Binary files /dev/null and b/backend/src/core/.DS_Store differ diff --git a/backend/src/core/config/.DS_Store b/backend/src/core/config/.DS_Store new file mode 100644 index 0000000..8890d0b Binary files /dev/null and b/backend/src/core/config/.DS_Store differ 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 0000000..748e82e Binary files /dev/null and b/backend/src/core/config/static/.DS_Store differ 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",