feat: 添加好友功能并集成 LiteLLM 代理服务
- 新增好友搜索、添加、好友列表功能 - 集成 LiteLLM 代理服务及多模型定价配置 - 更新 iOS CocoaPods 配置 - 更新 .gitignore 和环境变量配置
This commit is contained in:
@@ -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 配置
|
||||
############
|
||||
|
||||
+8
-2
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
4F9D784F5CA1FA42C9C1C04D /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1ACBA06AC19BA16E8A19E32E /* Pods_Runner.framework */,
|
||||
90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -94,6 +136,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
72B87A4C26233A1630FEF9DB /* Pods */,
|
||||
4F9D784F5CA1FA42C9C1C04D /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> configureDependencies() async {
|
||||
final usersApi = UsersApi(apiClient);
|
||||
sl.registerSingleton<UsersApi>(usersApi);
|
||||
|
||||
final friendsApi = FriendsApi(apiClient);
|
||||
sl.registerSingleton<FriendsApi>(friendsApi);
|
||||
|
||||
final authRepository = AuthRepositoryImpl(
|
||||
api: authApi,
|
||||
tokenStorage: tokenStorage,
|
||||
|
||||
@@ -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<ContactsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
final _searchFocusNode = FocusNode();
|
||||
|
||||
final List<ContactItem> _recentContacts = [
|
||||
ContactItem(
|
||||
name: 'Toki',
|
||||
email: 'toki@xunmee.com',
|
||||
color: AppColors.blue500,
|
||||
),
|
||||
ContactItem(
|
||||
name: 'Mina',
|
||||
email: 'mina@xunmee.com',
|
||||
color: AppColors.violet600,
|
||||
),
|
||||
];
|
||||
List<FriendResponse> _friends = [];
|
||||
List<UserResponse> _searchResults = [];
|
||||
bool _isLoading = true;
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
final Set<String> _sentRequestIds = {};
|
||||
Set<String> _friendIds = {};
|
||||
|
||||
final List<ContactItem> _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<void> _loadFriends() async {
|
||||
try {
|
||||
final friendsApi = sl<FriendsApi>();
|
||||
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<void> _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<UsersApi>();
|
||||
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<void> _sendFriendRequest(String targetUserId, String? content) async {
|
||||
try {
|
||||
final friendsApi = sl<FriendsApi>();
|
||||
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<ContactsScreen> {
|
||||
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<ContactsScreen> {
|
||||
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<ContactsScreen> {
|
||||
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<ContactsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactCard(List<ContactItem> contacts) {
|
||||
Widget _buildContactCard(List<FriendResponse> friends) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
@@ -152,9 +515,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
),
|
||||
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<ContactsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<ContactsScreen> {
|
||||
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<ContactsScreen> {
|
||||
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<ContactsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<ContactsScreen> {
|
||||
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});
|
||||
}
|
||||
|
||||
@@ -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<List<FriendResponse>> getFriends() async {
|
||||
final response = await _client.get(_prefix);
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => FriendResponse.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
Future<List<FriendResponse>> getIncomingRequests() async {
|
||||
final response = await _client.get('$_prefix/requests/inbox');
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => FriendResponse.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
Future<List<FriendResponse>> getOutgoingRequests() async {
|
||||
final response = await _client.get('$_prefix/requests/outgoing');
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => FriendResponse.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
Future<FriendResponse> sendRequest(String targetUserId) async {
|
||||
final response = await _client.post(
|
||||
'$_prefix/requests',
|
||||
data: {'target_user_id': targetUserId},
|
||||
);
|
||||
return FriendResponse.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<FriendResponse> acceptRequest(String friendshipId) async {
|
||||
final response = await _client.post(
|
||||
'$_prefix/requests/$friendshipId/accept',
|
||||
);
|
||||
return FriendResponse.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<FriendResponse> declineRequest(String friendshipId) async {
|
||||
final response = await _client.post(
|
||||
'$_prefix/requests/$friendshipId/decline',
|
||||
);
|
||||
return FriendResponse.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<void> 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<String, dynamic> json) {
|
||||
return FriendResponse(
|
||||
id: json['id'] as String,
|
||||
friend: UserBasicInfo.fromJson(json['friend'] as Map<String, dynamic>),
|
||||
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<String, dynamic> json) {
|
||||
return UserBasicInfo(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsScreen> {
|
||||
UserResponse? _user;
|
||||
bool _isLoading = true;
|
||||
int _friendsCount = 0;
|
||||
String? _firstFriendName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUser();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadUser() async {
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
final usersApi = sl<UsersApi>();
|
||||
final user = await usersApi.getMe();
|
||||
final friendsApi = sl<FriendsApi>();
|
||||
|
||||
final results = await Future.wait([
|
||||
usersApi.getMe(),
|
||||
friendsApi.getFriends(),
|
||||
]);
|
||||
|
||||
final user = results[0] as UserResponse;
|
||||
final friends = results[1] as List<FriendResponse>;
|
||||
|
||||
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<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<SettingsScreen> {
|
||||
iconBg: AppColors.surfaceTertiary,
|
||||
iconBorder: const Color(0xFFE6ECF7),
|
||||
title: '联系人',
|
||||
subtitle: '已添加 1 位:Toki',
|
||||
subtitle: _buildFriendsSubtitle(),
|
||||
onTap: () => context.push('/contacts'),
|
||||
),
|
||||
),
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+10
-8
@@ -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
|
||||
|
||||
@@ -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())
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from services.litellm.service import (
|
||||
LiteLLMResponseWithCost,
|
||||
LiteLLMService,
|
||||
LiteLLMUsage,
|
||||
)
|
||||
|
||||
__all__ = ["LiteLLMService", "LiteLLMUsage", "LiteLLMResponseWithCost"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>确认邮箱</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:24px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#1f2937;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:12px;padding:24px;">
|
||||
<tr>
|
||||
<td>
|
||||
<h2 style="margin:0 0 12px;font-size:22px;line-height:1.4;">请确认你的邮箱</h2>
|
||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.7;">你好,{{ .Email }}:</p>
|
||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.7;">请输入以下 6 位验证码完成注册:</p>
|
||||
<p style="margin:0 0 20px;font-size:28px;letter-spacing:6px;font-weight:700;color:#111827;">{{ .Token }}</p>
|
||||
<p style="margin:0 0 20px;font-size:13px;line-height:1.7;color:#4b5563;">验证码有效期较短,请尽快完成验证。</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>重置密码</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:24px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#1f2937;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:12px;padding:24px;">
|
||||
<tr>
|
||||
<td>
|
||||
<h2 style="margin:0 0 12px;font-size:22px;line-height:1.4;">重置你的账户密码</h2>
|
||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.7;">你好,{{ .Email }}:</p>
|
||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.7;">如果你使用验证码方式,请输入以下 6 位验证码:</p>
|
||||
<p style="margin:0 0 20px;font-size:28px;letter-spacing:6px;font-weight:700;color:#111827;">{{ .Token }}</p>
|
||||
<p style="margin:0 0 20px;font-size:13px;line-height:1.7;color:#4b5563;">验证码有效期较短,请尽快完成重置流程。</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
+66
-12
@@ -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."
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user