├── RLLM ├── Services │ ├── ImageCache.swift │ ├── QuotesExample.md │ ├── FeedStorage.swift │ ├── SummaryCache.swift │ ├── InsightCache.swift │ ├── StorageService.swift │ ├── DailySummaryCache.swift │ ├── CleanupService.swift │ ├── LLMConnections.swift │ ├── DataMigrationService.swift │ ├── RSSService.swift │ ├── RSSParser.swift │ ├── CacheManager.swift │ └── ExportManager.swift ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── appstore.png │ │ ├── Group 1-4.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── RLLM.entitlements ├── Models │ ├── ViewModels │ │ ├── SettingsViewModel.swift │ │ ├── LLMSettingsViewModel.swift │ │ └── QuotesViewModel.swift │ ├── RSSError.swift │ ├── Quote.swift │ ├── ArticleInsight.swift │ ├── HapticManager.swift │ ├── CacheConfig.swift │ ├── Feed.swift │ ├── Errors │ │ └── AIAnalysisError.swift │ ├── CardTheme.swift │ ├── Article.swift │ ├── ToastManager.swift │ ├── LLMPrompts.swift │ ├── LLMTypes.swift │ ├── RLLM.xcdatamodeld │ │ └── RLLM.xcdatamodel │ │ │ └── contents │ └── ReadingHistory.swift ├── Extensions │ ├── String+HTML.swift │ ├── Bundle+Extensions.swift │ └── DateFormatter+Extensions.swift ├── Views │ ├── Components │ │ ├── SafariView.swift │ │ ├── ErrorView.swift │ │ └── ModelCard.swift │ ├── Articles │ │ ├── FeedArticlesView.swift │ │ ├── ArticleRowView.swift │ │ ├── ArticleListView.swift │ │ ├── AddFeedView.swift │ │ ├── ArticlesListView.swift │ │ └── Components │ │ │ ├── FeedEditView.swift │ │ │ └── FeedCardView.swift │ ├── ContentView.swift │ ├── Settings │ │ ├── FeedManagementView.swift │ │ ├── ModelSelectionView.swift │ │ ├── AICacheManagementView.swift │ │ └── ReadingHistoryView.swift │ ├── Quotes │ │ ├── QuoteDetailView.swift │ │ ├── QuoteRowView.swift │ │ └── QuotesListView.swift │ └── AIInsights │ │ └── ArticleInsightView.swift ├── Info.plist └── App │ └── RLLMApp.swift ├── icon.png ├── Screenshots ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── screenshot4.png ├── swiftgen.yml ├── RLLM.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── swiftpm │ └── Package.resolved ├── Package.swift ├── localization.sh ├── .bartycrouch.toml ├── Package.resolved ├── LICENSE ├── .gitignore ├── .github └── workflows │ └── swift.yml ├── README_CN.md └── README.md /RLLM/Services/ImageCache.swift: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielZhangyc/RLLM/HEAD/icon.png -------------------------------------------------------------------------------- /Screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielZhangyc/RLLM/HEAD/Screenshots/screenshot1.png -------------------------------------------------------------------------------- /Screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielZhangyc/RLLM/HEAD/Screenshots/screenshot2.png -------------------------------------------------------------------------------- /Screenshots/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielZhangyc/RLLM/HEAD/Screenshots/screenshot3.png -------------------------------------------------------------------------------- /Screenshots/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielZhangyc/RLLM/HEAD/Screenshots/screenshot4.png -------------------------------------------------------------------------------- /RLLM/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RLLM/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RLLM/Assets.xcassets/AppIcon.appiconset/appstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielZhangyc/RLLM/HEAD/RLLM/Assets.xcassets/AppIcon.appiconset/appstore.png -------------------------------------------------------------------------------- /RLLM/Assets.xcassets/AppIcon.appiconset/Group 1-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielZhangyc/RLLM/HEAD/RLLM/Assets.xcassets/AppIcon.appiconset/Group 1-4.png -------------------------------------------------------------------------------- /swiftgen.yml: -------------------------------------------------------------------------------- 1 | strings: 2 | inputs: 3 | - RLLM/Resources/en.lproj/Localizable.strings 4 | outputs: 5 | - templateName: structured-swift5 6 | output: RLLM/Generated/Strings.swift -------------------------------------------------------------------------------- /RLLM.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RLLM/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /RLLM/RLLM.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /RLLM/Models/ViewModels/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @MainActor 4 | class SettingsViewModel: ObservableObject { 5 | @Published var feedCount: Int = 0 6 | @Published var autoGenerateSummary = false 7 | 8 | init() {} 9 | 10 | func updateFeedCount(feeds: [Feed]) { 11 | feedCount = feeds.count 12 | } 13 | } -------------------------------------------------------------------------------- /RLLM/Extensions/String+HTML.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | func removingHTMLTags() -> String { 5 | self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) 6 | .replacingOccurrences(of: "&[^;]+;", with: "", options: .regularExpression) 7 | .trimmingCharacters(in: .whitespacesAndNewlines) 8 | } 9 | } -------------------------------------------------------------------------------- /RLLM/Views/Components/SafariView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SafariServices 3 | 4 | struct SafariView: UIViewControllerRepresentable { 5 | let url: URL 6 | 7 | func makeUIViewController(context: Context) -> SFSafariViewController { 8 | return SFSafariViewController(url: url) 9 | } 10 | 11 | func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { 12 | // 不需要更新 13 | } 14 | } -------------------------------------------------------------------------------- /RLLM/Services/QuotesExample.md: -------------------------------------------------------------------------------- 1 | # RSS阅读收藏导出 2 | 导出时间:{ExportDateTime} 3 | 收藏时间范围:{StartDate} 至 {EndDate} 4 | 5 | ## 统计信息 6 | - 总收藏数:{TotalCount} 7 | - 全文收藏:{FullArticleCount} 8 | - 片段收藏:{QuoteCount} 9 | - 来源网站:{SourceCount} 10 | - 标签统计:{TagStats} 11 | 12 | ## 全文收藏 13 | ### {Title1} 14 | - 收藏时间:{SavedDate1} 15 | - 来源:[{Source1}]({Link1}) 16 | - 作者:{Author1} 17 | - 标签:#Tag11 #Tag12 #Tag13 18 | - 阅读时间:{ReadingTime1} 19 | 20 | --- 21 | 22 | ## 片段收藏 23 | ### {Title2} 24 | - 收藏时间:{SavedDate2} 25 | - 来源:[{Source2}]({Link2}) 26 | - 上下文:{Context2} 27 | - 标签:#Tag21 #Tag22 #Tag23 28 | 29 | > {QuoteContent2} 30 | -------------------------------------------------------------------------------- /RLLM/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleURLName 9 | xy0v0.RLLM 10 | CFBundleURLSchemes 11 | 12 | rllm 13 | 14 | 15 | 16 | NSAppTransportSecurity 17 | 18 | NSAllowsArbitraryLoads 19 | 20 | 21 | CFBundleVersion 22 | N/A 23 | 24 | 25 | -------------------------------------------------------------------------------- /RLLM/Extensions/Bundle+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Bundle { 4 | /// 获取应用版本号和构建号 5 | var versionAndBuild: String { 6 | let version = infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" 7 | let build = infoDictionary?["CFBundleVersion"] as? String ?? "1" 8 | return "\(version) (\(build))" 9 | } 10 | 11 | /// 获取应用版本号 12 | var version: String { 13 | return infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" 14 | } 15 | 16 | /// 获取应用构建号 17 | var build: String { 18 | return infoDictionary?["CFBundleVersion"] as? String ?? "1" 19 | } 20 | } -------------------------------------------------------------------------------- /RLLM/Views/Articles/FeedArticlesView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FeedArticlesView: View { 4 | let feed: Feed 5 | @EnvironmentObject var articlesViewModel: ArticlesViewModel 6 | 7 | var feedArticles: [Article] { 8 | articlesViewModel.articles.filter { $0.feedTitle == feed.title } 9 | } 10 | 11 | var body: some View { 12 | List(feedArticles) { article in 13 | NavigationLink { 14 | ArticleDetailView(article: article) 15 | } label: { 16 | ArticleRowView(article: article) 17 | } 18 | } 19 | .navigationTitle(feed.title) 20 | } 21 | } -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "RLLM", 6 | defaultLocalization: "en", 7 | platforms: [ 8 | .iOS(.v17) 9 | ], 10 | dependencies: [ 11 | .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.1"), 12 | .package(url: "https://github.com/nmdias/FeedKit.git", from: "9.1.2"), 13 | .package(url: "https://github.com/BastiaanJansen/toast-swift", from: "2.1.3") 14 | ], 15 | targets: [ 16 | .target( 17 | name: "RLLM", 18 | dependencies: [ 19 | "Alamofire", 20 | "FeedKit", 21 | "Toast" 22 | ], 23 | path: "RLLM" 24 | ) 25 | ] 26 | ) -------------------------------------------------------------------------------- /RLLM/Extensions/DateFormatter+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension DateFormatter { 4 | static let yyyyMMdd: DateFormatter = { 5 | let formatter = DateFormatter() 6 | formatter.dateFormat = "yyyy-MM-dd" 7 | formatter.calendar = Calendar(identifier: .gregorian) 8 | formatter.timeZone = TimeZone.current 9 | formatter.locale = Locale(identifier: "zh_CN") 10 | return formatter 11 | }() 12 | 13 | static let timeOnly: DateFormatter = { 14 | let formatter = DateFormatter() 15 | formatter.dateFormat = "HH:mm" 16 | formatter.calendar = Calendar(identifier: .gregorian) 17 | formatter.timeZone = TimeZone.current 18 | formatter.locale = Locale(identifier: "zh_CN") 19 | return formatter 20 | }() 21 | } -------------------------------------------------------------------------------- /localization.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 检查BartyCrouch是否安装 4 | if ! which bartycrouch > /dev/null; then 5 | echo "错误: BartyCrouch未安装" 6 | echo "请运行: brew install bartycrouch" 7 | exit 1 8 | fi 9 | 10 | # 检查SwiftGen是否安装 11 | if ! which swiftgen > /dev/null; then 12 | echo "错误: SwiftGen未安装" 13 | echo "请运行: brew install swiftgen" 14 | exit 1 15 | fi 16 | 17 | echo "开始更新本地化文件..." 18 | 19 | # 运行BartyCrouch 20 | echo "运行BartyCrouch..." 21 | if ! bartycrouch update -x; then 22 | echo "错误: BartyCrouch更新失败" 23 | exit 1 24 | fi 25 | 26 | if ! bartycrouch lint -x; then 27 | echo "警告: BartyCrouch检查发现问题" 28 | # 不退出,因为lint错误可能不影响功能 29 | fi 30 | 31 | # 运行SwiftGen 32 | echo "运行SwiftGen..." 33 | if ! swiftgen; then 34 | echo "错误: SwiftGen生成失败" 35 | exit 1 36 | fi 37 | 38 | echo "本地化处理完成!" -------------------------------------------------------------------------------- /RLLM/Views/Articles/ArticleRowView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ArticleRowView: View { 4 | let article: Article 5 | 6 | var body: some View { 7 | VStack(alignment: .leading, spacing: 8) { 8 | Text(article.title) 9 | .font(.headline) 10 | .lineLimit(2) 11 | 12 | HStack { 13 | Text(article.feedTitle) 14 | .font(.caption) 15 | .foregroundColor(.secondary) 16 | 17 | Spacer() 18 | 19 | Text(article.publishDate.formatted(.relative(presentation: .named))) 20 | .font(.caption) 21 | .foregroundColor(.secondary) 22 | } 23 | } 24 | .padding(.vertical, 4) 25 | .contentShape(Rectangle()) 26 | } 27 | } -------------------------------------------------------------------------------- /RLLM/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "appstore.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "Group 1-4.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "idiom" : "universal", 29 | "platform" : "ios", 30 | "size" : "1024x1024" 31 | } 32 | ], 33 | "info" : { 34 | "author" : "xcode", 35 | "version" : 1 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /RLLM/Models/RSSError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// RSS相关操作的错误类型 4 | enum RSSError: Error { 5 | /// 无效的URL 6 | case invalidURL 7 | /// 获取错误 8 | case fetchError(Error) 9 | /// 解析错误 10 | case parseError(Error) 11 | /// 重复的Feed 12 | case duplicateFeed 13 | /// 无效的Feed数据 14 | case invalidFeed 15 | } 16 | 17 | // MARK: - LocalizedError 18 | 19 | extension RSSError: LocalizedError { 20 | var errorDescription: String? { 21 | switch self { 22 | case .invalidURL: 23 | return "无效的URL" 24 | case .fetchError(let error): 25 | return "获取错误: \(error.localizedDescription)" 26 | case .parseError(let error): 27 | return "解析错误: \(error.localizedDescription)" 28 | case .duplicateFeed: 29 | return "订阅源已存在" 30 | case .invalidFeed: 31 | return "无效的Feed数据" 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /.bartycrouch.toml: -------------------------------------------------------------------------------- 1 | [update] 2 | tasks = ["interfaces", "code", "normalize"] 3 | 4 | [update.interfaces] 5 | paths = ["RLLM"] 6 | defaultToBase = false 7 | ignoreEmptyStrings = false 8 | unstripped = false 9 | 10 | [update.code] 11 | codePaths = ["RLLM"] 12 | localizablePaths = ["RLLM"] 13 | defaultToKeys = false 14 | additive = true 15 | customFunction = "NSLocalizedString" 16 | unstripped = false 17 | 18 | [update.normalize] 19 | paths = ["RLLM"] 20 | sourceLocale = "en" 21 | harmonizeWithSource = true 22 | sortByKeys = true 23 | 24 | [update.transform] 25 | codePaths = ["RLLM"] 26 | localizablePaths = ["RLLM"] 27 | transformer = "swiftgenStructured" 28 | supportedLanguageEnumPath = "RLLM/Generated" 29 | typeName = "BartyCrouch" 30 | translateMethodName = "translate" 31 | 32 | [lint] 33 | paths = ["."] 34 | subpathsToIgnore = [".git", "carthage", "pods", "build", ".build", "docs"] 35 | duplicateKeys = true 36 | emptyValues = true 37 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "alamofire", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/Alamofire/Alamofire.git", 7 | "state" : { 8 | "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", 9 | "version" : "5.10.2" 10 | } 11 | }, 12 | { 13 | "identity" : "feedkit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/nmdias/FeedKit.git", 16 | "state" : { 17 | "revision" : "68493a33d862c33c9a9f67ec729b3b7df1b20ade", 18 | "version" : "9.1.2" 19 | } 20 | }, 21 | { 22 | "identity" : "toast-swift", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/BastiaanJansen/toast-swift", 25 | "state" : { 26 | "revision" : "b8f0de845abca47eee747b6e671f1141ec4dcf3e", 27 | "version" : "2.1.3" 28 | } 29 | } 30 | ], 31 | "version" : 2 32 | } 33 | -------------------------------------------------------------------------------- /RLLM/Models/Quote.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 文章引用模型 4 | /// 用于保存用户在阅读文章时标记的重要引用内容 5 | /// 6 | /// 未来功能: 7 | /// - 支持用户在阅读时长按选择文本保存为引用 8 | /// - 在"收藏"标签页中展示所有保存的引用 9 | /// - 支持为引用添加标签和笔记 10 | /// - 支持引用的分类管理和搜索 11 | struct Quote: Identifiable, Codable { 12 | let id: UUID 13 | let content: String // 引用内容 14 | let articleTitle: String // 来源文章标题 15 | let articleURL: String // 来源文章链接 16 | let savedDate: Date // 保存时间 17 | let isFullArticle: Bool // 是否为全文收藏 18 | var isSelected: Bool // 是否被选中 19 | 20 | init(id: UUID = UUID(), 21 | content: String, 22 | articleTitle: String, 23 | articleURL: String, 24 | savedDate: Date = Date(), 25 | isFullArticle: Bool = false, 26 | isSelected: Bool = false) { 27 | self.id = id 28 | self.content = content 29 | self.articleTitle = articleTitle 30 | self.articleURL = articleURL 31 | self.savedDate = savedDate 32 | self.isFullArticle = isFullArticle 33 | self.isSelected = isSelected 34 | } 35 | } -------------------------------------------------------------------------------- /RLLM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "2138655f6b221350d0ed6f98e2f041086a36d8fe56868ce21dcbf3508e2d69aa", 3 | "pins" : [ 4 | { 5 | "identity" : "alamofire", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/Alamofire/Alamofire", 8 | "state" : { 9 | "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", 10 | "version" : "5.10.2" 11 | } 12 | }, 13 | { 14 | "identity" : "feedkit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/nmdias/FeedKit.git", 17 | "state" : { 18 | "revision" : "68493a33d862c33c9a9f67ec729b3b7df1b20ade", 19 | "version" : "9.1.2" 20 | } 21 | }, 22 | { 23 | "identity" : "toast-swift", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/BastiaanJansen/toast-swift", 26 | "state" : { 27 | "revision" : "b8f0de845abca47eee747b6e671f1141ec4dcf3e", 28 | "version" : "2.1.3" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LLM-RSS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /RLLM/Views/Components/ErrorView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 通用错误提示视图 4 | struct ErrorView: View { 5 | /// 错误信息 6 | let error: Error 7 | 8 | /// 重试回调 9 | let retryAction: () -> Void 10 | 11 | /// 是否显示重试按钮 12 | var showRetryButton: Bool = true 13 | 14 | var body: some View { 15 | VStack(spacing: 16) { 16 | Image(systemName: "exclamationmark.triangle") 17 | .font(.largeTitle) 18 | .foregroundColor(.red) 19 | 20 | Text(error.localizedDescription) 21 | .multilineTextAlignment(.center) 22 | .foregroundColor(.primary) 23 | 24 | if let recoverySuggestion = (error as? LocalizedError)?.recoverySuggestion { 25 | Text(recoverySuggestion) 26 | .font(.subheadline) 27 | .multilineTextAlignment(.center) 28 | .foregroundColor(.secondary) 29 | } 30 | 31 | if showRetryButton { 32 | Button(action: retryAction) { 33 | Label(NSLocalizedString("error.retry", comment: "Retry button"), systemImage: "arrow.clockwise") 34 | } 35 | .buttonStyle(.bordered) 36 | .tint(.accentColor) 37 | } 38 | } 39 | .padding() 40 | .frame(maxWidth: .infinity) 41 | } 42 | } -------------------------------------------------------------------------------- /RLLM/Services/FeedStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Feed存储错误类型 4 | enum FeedStorageError: Error { 5 | /// 编码错误 6 | case encodingError 7 | /// 解码错误 8 | case decodingError 9 | } 10 | 11 | /// Feed存储服务,负责管理RSS订阅源的持久化存储 12 | final class FeedStorage { 13 | // MARK: - Singleton 14 | 15 | /// 共享实例 16 | static let shared = FeedStorage() 17 | 18 | // MARK: - Constants 19 | 20 | /// UserDefaults中存储Feed列表的键 21 | private let feedsKey = "savedFeeds" 22 | 23 | // MARK: - Initialization 24 | 25 | /// 私有初始化方法,确保单例模式 26 | private init() {} 27 | 28 | // MARK: - Public Methods 29 | 30 | /// 从持久化存储中加载所有Feed 31 | /// - Returns: Feed列表 32 | /// - Throws: FeedStorageError.decodingError 当解码失败时 33 | func loadFeeds() throws -> [Feed] { 34 | guard let data = UserDefaults.standard.data(forKey: feedsKey) else { 35 | return [] 36 | } 37 | 38 | do { 39 | return try JSONDecoder().decode([Feed].self, from: data) 40 | } catch { 41 | throw FeedStorageError.decodingError 42 | } 43 | } 44 | 45 | /// 保存Feed列表到持久化存储 46 | /// - Parameter feeds: 要保存的Feed列表 47 | /// - Throws: FeedStorageError.encodingError 当编码失败时 48 | func save(_ feeds: [Feed]) throws { 49 | do { 50 | let data = try JSONEncoder().encode(feeds) 51 | UserDefaults.standard.set(data, forKey: feedsKey) 52 | } catch { 53 | throw FeedStorageError.encodingError 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /RLLM/Models/ArticleInsight.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 表示文章的AI分析洞察结果 4 | struct ArticleInsight: Identifiable, Codable, Equatable { 5 | // MARK: - Properties 6 | 7 | /// 洞察结果的唯一标识符 8 | let id: UUID 9 | 10 | /// 文章的核心摘要 11 | let summary: String 12 | 13 | /// 文章的关键观点列表 14 | let keyPoints: [String] 15 | 16 | /// 文章的主题标签 17 | let topics: [String] 18 | 19 | /// 文章的情感倾向描述 20 | let sentiment: String 21 | 22 | /// 相关背景信息补充 23 | let backgroundInfo: String? 24 | 25 | // MARK: - Initialization 26 | 27 | /// 创建一个新的ArticleInsight实例 28 | /// - Parameters: 29 | /// - id: 唯一标识符,默认自动生成 30 | /// - summary: 文章核心摘要 31 | /// - keyPoints: 关键观点列表 32 | /// - topics: 主题标签列表 33 | /// - sentiment: 情感倾向描述 34 | /// - backgroundInfo: 背景信息补充,可选 35 | init( 36 | id: UUID = UUID(), 37 | summary: String, 38 | keyPoints: [String], 39 | topics: [String], 40 | sentiment: String, 41 | backgroundInfo: String? = nil 42 | ) { 43 | self.id = id 44 | self.summary = summary 45 | self.keyPoints = keyPoints 46 | self.topics = topics 47 | self.sentiment = sentiment 48 | self.backgroundInfo = backgroundInfo 49 | } 50 | 51 | // MARK: - Equatable 52 | 53 | static func == (lhs: ArticleInsight, rhs: ArticleInsight) -> Bool { 54 | lhs.id == rhs.id && 55 | lhs.summary == rhs.summary && 56 | lhs.keyPoints == rhs.keyPoints && 57 | lhs.topics == rhs.topics && 58 | lhs.sentiment == rhs.sentiment && 59 | lhs.backgroundInfo == rhs.backgroundInfo 60 | } 61 | } -------------------------------------------------------------------------------- /RLLM/Services/SummaryCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 文章摘要缓存管理 4 | class SummaryCache { 5 | /// 单例实例 6 | static let shared = SummaryCache() 7 | 8 | /// 缓存管理器 9 | private let cacheManager: CacheManager 10 | 11 | private init() { 12 | cacheManager = CacheManager(directoryName: "summaries") 13 | } 14 | 15 | /// 获取缓存的摘要 16 | /// - Parameter articleId: 文章ID 17 | /// - Returns: 缓存的摘要,如果不存在或已过期则返回nil 18 | func get(for articleId: String) -> String? { 19 | do { 20 | let data = try cacheManager.read(for: articleId) 21 | if !data.isEmpty { 22 | return String(data: data, encoding: .utf8) 23 | } 24 | } catch { 25 | print("读取摘要缓存失败:\(error.localizedDescription)") 26 | } 27 | return nil 28 | } 29 | 30 | /// 保存摘要到缓存 31 | /// - Parameters: 32 | /// - summary: 摘要内容 33 | /// - articleId: 文章ID 34 | func set(_ summary: String, for articleId: String) { 35 | guard let data = summary.data(using: .utf8) else { return } 36 | do { 37 | try cacheManager.write(data, for: articleId) 38 | } catch { 39 | print("保存摘要缓存失败:\(error.localizedDescription)") 40 | } 41 | } 42 | 43 | /// 检查是否存在缓存 44 | /// - Parameter articleId: 文章ID 45 | /// - Returns: 是否存在有效缓存 46 | func has(for articleId: String) -> Bool { 47 | cacheManager.has(for: articleId) 48 | } 49 | 50 | /// 清除所有缓存 51 | func clear() { 52 | cacheManager.clearAll() 53 | } 54 | 55 | /// 获取缓存统计信息 56 | /// - Returns: 缓存统计信息 57 | func getStats() -> CacheStats { 58 | cacheManager.getStats() 59 | } 60 | } -------------------------------------------------------------------------------- /RLLM/Services/InsightCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// AI洞察缓存管理 4 | class InsightCache { 5 | /// 单例实例 6 | static let shared = InsightCache() 7 | 8 | /// 缓存管理器 9 | private let cacheManager: CacheManager 10 | 11 | private init() { 12 | cacheManager = CacheManager(directoryName: "insights") 13 | } 14 | 15 | /// 获取缓存的洞察结果 16 | /// - Parameter articleId: 文章ID 17 | /// - Returns: 缓存的洞察结果,如果不存在或已过期则返回nil 18 | func get(for articleId: String) -> ArticleInsight? { 19 | do { 20 | let data = try cacheManager.read(for: articleId) 21 | if !data.isEmpty { 22 | return try JSONDecoder().decode(ArticleInsight.self, from: data) 23 | } 24 | } catch { 25 | print("读取洞察缓存失败:\(error.localizedDescription)") 26 | } 27 | return nil 28 | } 29 | 30 | /// 保存洞察结果到缓存 31 | /// - Parameters: 32 | /// - insight: 洞察结果 33 | /// - articleId: 文章ID 34 | func set(_ insight: ArticleInsight, for articleId: String) { 35 | do { 36 | let data = try JSONEncoder().encode(insight) 37 | try cacheManager.write(data, for: articleId) 38 | } catch { 39 | print("保存洞察缓存失败:\(error.localizedDescription)") 40 | } 41 | } 42 | 43 | /// 检查是否存在缓存 44 | /// - Parameter articleId: 文章ID 45 | /// - Returns: 是否存在有效缓存 46 | func has(for articleId: String) -> Bool { 47 | cacheManager.has(for: articleId) 48 | } 49 | 50 | /// 清除所有缓存 51 | func clear() { 52 | cacheManager.clearAll() 53 | } 54 | 55 | /// 获取缓存统计信息 56 | /// - Returns: 缓存统计信息 57 | func getStats() -> CacheStats { 58 | cacheManager.getStats() 59 | } 60 | } -------------------------------------------------------------------------------- /RLLM/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | @EnvironmentObject var articlesViewModel: ArticlesViewModel 5 | @StateObject var llmViewModel = LLMSettingsViewModel() 6 | @StateObject var aiInsightsViewModel = AIInsightsViewModel() 7 | @StateObject var quotesViewModel = QuotesViewModel.shared 8 | 9 | var body: some View { 10 | TabView { 11 | NavigationStack { 12 | ArticlesListView() 13 | .environmentObject(quotesViewModel) 14 | } 15 | .tabItem { 16 | Label(NSLocalizedString("tab.articles", comment: "Articles tab"), systemImage: "doc.text.fill") 17 | } 18 | 19 | NavigationStack { 20 | QuotesListView() 21 | .environmentObject(quotesViewModel) 22 | } 23 | .tabItem { 24 | Label(NSLocalizedString("tab.quotes", comment: "Quotes tab"), systemImage: "bookmark.fill") 25 | } 26 | 27 | NavigationStack { 28 | AIInsightsView() 29 | } 30 | .tabItem { 31 | Label(NSLocalizedString("tab.ai_summary", comment: "AI Summary tab"), systemImage: "brain.fill") 32 | } 33 | 34 | NavigationStack { 35 | SettingsView() 36 | } 37 | .tabItem { 38 | Label(NSLocalizedString("tab.settings", comment: "Settings tab"), systemImage: "gearshape.fill") 39 | } 40 | } 41 | .environmentObject(articlesViewModel) 42 | .environmentObject(llmViewModel) 43 | .environmentObject(aiInsightsViewModel) 44 | .environmentObject(quotesViewModel) 45 | } 46 | } 47 | 48 | #Preview { 49 | ContentView() 50 | } -------------------------------------------------------------------------------- /RLLM/Models/HapticManager.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// 触觉反馈管理器 4 | class HapticManager { 5 | 6 | /// 单例实例 7 | static let shared = HapticManager() 8 | 9 | /// 私有初始化方法 10 | private init() {} 11 | 12 | // MARK: - Impact Feedback 13 | 14 | /// 生成轻微的触觉反馈 15 | func lightImpact() { 16 | let generator = UIImpactFeedbackGenerator(style: .light) 17 | generator.prepare() 18 | generator.impactOccurred() 19 | } 20 | 21 | /// 生成中等强度的触觉反馈 22 | func mediumImpact() { 23 | let generator = UIImpactFeedbackGenerator(style: .medium) 24 | generator.prepare() 25 | generator.impactOccurred() 26 | } 27 | 28 | /// 生成重度的触觉反馈 29 | func heavyImpact() { 30 | let generator = UIImpactFeedbackGenerator(style: .heavy) 31 | generator.prepare() 32 | generator.impactOccurred() 33 | } 34 | 35 | // MARK: - Notification Feedback 36 | 37 | /// 生成成功的触觉反馈 38 | func success() { 39 | let generator = UINotificationFeedbackGenerator() 40 | generator.prepare() 41 | generator.notificationOccurred(.success) 42 | } 43 | 44 | /// 生成警告的触觉反馈 45 | func warning() { 46 | let generator = UINotificationFeedbackGenerator() 47 | generator.prepare() 48 | generator.notificationOccurred(.warning) 49 | } 50 | 51 | /// 生成错误的触觉反馈 52 | func error() { 53 | let generator = UINotificationFeedbackGenerator() 54 | generator.prepare() 55 | generator.notificationOccurred(.error) 56 | } 57 | 58 | // MARK: - Selection Feedback 59 | 60 | /// 生成选择变更的触觉反馈 61 | func selection() { 62 | let generator = UISelectionFeedbackGenerator() 63 | generator.prepare() 64 | generator.selectionChanged() 65 | } 66 | } -------------------------------------------------------------------------------- /RLLM/Models/CacheConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 缓存配置结构体 4 | /// 定义了缓存的各项参数限制 5 | struct CacheConfig { 6 | // MARK: - Static Properties 7 | 8 | /// 缓存过期时间(秒) 9 | /// 默认为15天 10 | static let expirationInterval: TimeInterval = 15 * 24 * 60 * 60 // 15天 11 | 12 | /// 最大缓存条目数 13 | /// 默认为1000条 14 | static let maxEntries: Int = 1000 15 | 16 | /// 单个缓存文件大小限制(字节) 17 | /// 默认为1MB 18 | static let maxFileSize: Int64 = 1024 * 1024 // 1MB 19 | 20 | /// 总缓存大小限制(字节) 21 | /// 默认为100MB 22 | static let maxTotalSize: Int64 = 100 * 1024 * 1024 // 100MB 23 | } 24 | 25 | /// 缓存条目信息结构体 26 | /// 记录每个缓存条目的元数据 27 | struct CacheEntryInfo: Codable { 28 | // MARK: - Properties 29 | 30 | /// 缓存条目的创建时间 31 | let createdAt: Date 32 | 33 | /// 缓存条目的最后访问时间 34 | var lastAccessedAt: Date 35 | 36 | /// 缓存文件的大小(字节) 37 | let fileSize: Int64 38 | 39 | // MARK: - Methods 40 | 41 | /// 检查缓存条目是否已过期 42 | /// - Returns: 如果缓存条目已过期则返回true,否则返回false 43 | func isExpired() -> Bool { 44 | Date().timeIntervalSince(createdAt) > CacheConfig.expirationInterval 45 | } 46 | } 47 | 48 | /// 缓存统计信息结构体 49 | /// 提供缓存系统的整体统计数据 50 | struct CacheStats { 51 | // MARK: - Properties 52 | 53 | /// 当前缓存条目的总数 54 | let entryCount: Int 55 | 56 | /// 当前总缓存大小(字节) 57 | let totalSize: Int64 58 | 59 | /// 已过期的缓存条目数量 60 | let expiredCount: Int 61 | 62 | /// 最早的缓存条目创建时间 63 | let oldestEntryDate: Date? 64 | 65 | /// 最新的缓存条目创建时间 66 | let newestEntryDate: Date? 67 | 68 | /// 缓存条目的平均存在时间 69 | let averageAge: TimeInterval? 70 | 71 | /// 缓存命中率 72 | let hitRate: Double 73 | 74 | /// 缓存使用率(占最大限制的百分比) 75 | var usagePercentage: Double { 76 | Double(totalSize) / Double(CacheConfig.maxTotalSize) * 100 77 | } 78 | } -------------------------------------------------------------------------------- /RLLM/Models/Feed.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 表示一个RSS订阅源的数据模型 4 | struct Feed: Identifiable, Codable { 5 | // MARK: - Properties 6 | 7 | /// 订阅源的唯一标识符 8 | let id: UUID 9 | 10 | /// 订阅源的标题 11 | var title: String 12 | 13 | /// 订阅源的URL地址 14 | let url: String 15 | 16 | /// 订阅源的描述信息 17 | let description: String? 18 | 19 | /// 订阅源的图标名称 20 | var iconName: String 21 | 22 | /// 订阅源的图标颜色 23 | var iconColor: String? 24 | 25 | // MARK: - Initialization 26 | 27 | /// 创建一个新的Feed实例 28 | /// - Parameters: 29 | /// - id: 唯一标识符,默认自动生成 30 | /// - title: 订阅源标题 31 | /// - url: 订阅源URL 32 | /// - description: 订阅源描述 33 | /// - iconName: 图标名称 34 | /// - iconColor: 图标颜色 35 | init( 36 | id: UUID = UUID(), 37 | title: String, 38 | url: String, 39 | description: String? = nil, 40 | iconName: String = "newspaper.fill", 41 | iconColor: String? = nil 42 | ) { 43 | self.id = id 44 | self.title = title 45 | self.url = url 46 | self.description = description 47 | self.iconName = iconName 48 | self.iconColor = iconColor 49 | } 50 | 51 | // MARK: - Methods 52 | 53 | /// 更新Feed的属性并返回新的实例 54 | /// - Parameters: 55 | /// - title: 新的标题,如果为nil则保持原值 56 | /// - iconName: 新的图标名称,如果为nil则保持原值 57 | /// - iconColor: 新的图标颜色,如果为nil则保持原值 58 | /// - Returns: 更新后的Feed实例 59 | func updating(title: String? = nil, iconName: String? = nil, iconColor: String? = nil) -> Feed { 60 | Feed( 61 | id: self.id, 62 | title: title ?? self.title, 63 | url: self.url, 64 | description: self.description, 65 | iconName: iconName ?? self.iconName, 66 | iconColor: iconColor ?? self.iconColor 67 | ) 68 | } 69 | } -------------------------------------------------------------------------------- /RLLM/Views/Settings/FeedManagementView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FeedManagementView: View { 4 | @EnvironmentObject var articlesViewModel: ArticlesViewModel 5 | @State private var showingAddFeed = false 6 | @State private var error: Error? 7 | @State private var showingError = false 8 | 9 | var body: some View { 10 | List { 11 | ForEach(articlesViewModel.feeds) { feed in 12 | VStack(alignment: .leading) { 13 | Text(feed.title) 14 | .font(.headline) 15 | Text(feed.url) 16 | .font(.caption) 17 | .foregroundColor(.secondary) 18 | } 19 | } 20 | .onDelete { indexSet in 21 | for index in indexSet { 22 | let feed = articlesViewModel.feeds[index] 23 | articlesViewModel.deleteFeed(feed) 24 | } 25 | } 26 | } 27 | .navigationTitle(NSLocalizedString("feed_management.title", comment: "Feed management title")) 28 | .navigationBarItems(trailing: Button(action: { 29 | showingAddFeed = true 30 | }) { 31 | Image(systemName: "plus") 32 | }) 33 | .sheet(isPresented: $showingAddFeed) { 34 | AddFeedView(viewModel: articlesViewModel) 35 | } 36 | .alert(NSLocalizedString("feed_management.error", comment: "Error alert title"), isPresented: $showingError) { 37 | Button(NSLocalizedString("feed_management.ok", comment: "OK button"), role: .cancel) {} 38 | } message: { 39 | Text(error?.localizedDescription ?? NSLocalizedString("feed_management.unknown_error", comment: "Unknown error message")) 40 | } 41 | } 42 | } 43 | 44 | #Preview { 45 | NavigationView { 46 | FeedManagementView() 47 | } 48 | } -------------------------------------------------------------------------------- /RLLM/Models/Errors/AIAnalysisError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// AI分析过程中可能出现的错误 4 | enum AIAnalysisError: LocalizedError { 5 | /// 配置相关错误 6 | case configurationError(String) 7 | /// 网络请求错误 8 | case networkError(Error) 9 | /// 响应解析错误 10 | case parseError(String) 11 | /// LLM服务错误 12 | case llmServiceError(String) 13 | /// 内容处理错误 14 | case contentError(String) 15 | 16 | var errorDescription: String? { 17 | switch self { 18 | case .configurationError(let message): 19 | return "配置错误:\(message)" 20 | case .networkError(let error): 21 | return "网络错误:\(error.localizedDescription)" 22 | case .parseError(let message): 23 | return "解析错误:\(message)" 24 | case .llmServiceError(let message): 25 | return "LLM服务错误:\(message)" 26 | case .contentError(let message): 27 | return "内容错误:\(message)" 28 | } 29 | } 30 | 31 | var recoverySuggestion: String? { 32 | switch self { 33 | case .configurationError: 34 | return "请检查LLM服务配置是否正确,确保已设置正确的API密钥和服务地址。" 35 | case .networkError: 36 | return "请检查网络连接是否正常,或稍后重试。" 37 | case .parseError: 38 | return "LLM响应格式异常,请重试或调整提示词。" 39 | case .llmServiceError: 40 | return "LLM服务异常,请检查服务状态或更换其他模型。" 41 | case .contentError: 42 | return "请确保文章内容不为空且格式正确。" 43 | } 44 | } 45 | 46 | var failureReason: String? { 47 | switch self { 48 | case .configurationError: 49 | return "LLM服务配置无效或缺失" 50 | case .networkError: 51 | return "网络请求失败" 52 | case .parseError: 53 | return "响应格式不符合预期" 54 | case .llmServiceError: 55 | return "LLM服务调用失败" 56 | case .contentError: 57 | return "文章内容处理失败" 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /RLLM/Models/ViewModels/LLMSettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class LLMSettingsViewModel: ObservableObject { 4 | @AppStorage("llmConfig") private var storedConfig: Data? 5 | 6 | @Published var config: LLMConfig 7 | @Published var isTestingConnection = false 8 | @Published var testResult: String? 9 | @Published var isLoadingModels = false 10 | @Published var error: Error? 11 | 12 | init() { 13 | let initialConfig: LLMConfig 14 | if let storedData = UserDefaults.standard.data(forKey: "llmConfig"), 15 | let decoded = try? JSONDecoder().decode(LLMConfig.self, from: storedData) { 16 | initialConfig = decoded 17 | } else { 18 | initialConfig = .defaultConfig 19 | } 20 | 21 | self.config = initialConfig 22 | self.isTestingConnection = false 23 | self.testResult = nil 24 | } 25 | 26 | func updateProvider(_ provider: LLMProvider) { 27 | config.provider = provider 28 | config.baseURL = provider.defaultBaseURL 29 | config.model = "" // 清空当前选择的模型 30 | saveConfig() 31 | } 32 | 33 | func saveConfig() { 34 | if let encoded = try? JSONEncoder().encode(config) { 35 | storedConfig = encoded 36 | } 37 | } 38 | 39 | func testConnection() { 40 | isTestingConnection = true 41 | testResult = nil 42 | 43 | Task { 44 | do { 45 | _ = try await LLMService.shared.fetchAvailableModels(config: config) 46 | await MainActor.run { 47 | self.testResult = NSLocalizedString("settings.test_success", comment: "Test success") 48 | } 49 | } catch { 50 | await MainActor.run { 51 | self.testResult = NSLocalizedString("settings.test_failed", comment: "Test failed") + ": \(error.localizedDescription)" 52 | } 53 | } 54 | await MainActor.run { 55 | self.isTestingConnection = false 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /RLLM/Views/Articles/ArticleListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 显示指定Feed的文章列表视图 4 | struct ArticleListView: View { 5 | // MARK: - Properties 6 | 7 | /// 当前显示的Feed 8 | let feed: Feed 9 | 10 | /// 文章视图模型 11 | @EnvironmentObject private var articlesViewModel: ArticlesViewModel 12 | 13 | /// 引用视图模型 14 | @EnvironmentObject private var quotesViewModel: QuotesViewModel 15 | 16 | /// 控制编辑sheet的显示状态 17 | @State private var showingEditSheet = false 18 | 19 | // MARK: - Body 20 | 21 | var body: some View { 22 | List { 23 | ForEach(articlesViewModel.getArticles(for: feed)) { article in 24 | NavigationLink(destination: LazyView( 25 | ArticleDetailView(article: article) 26 | .environmentObject(quotesViewModel) 27 | )) { 28 | ArticleRowView(article: article) 29 | } 30 | } 31 | } 32 | .navigationTitle(feed.title) 33 | .navigationBarTitleDisplayMode(.inline) 34 | .toolbar { 35 | ToolbarItem(placement: .navigationBarTrailing) { 36 | Button(action: { showingEditSheet = true }) { 37 | Image(systemName: "gear") 38 | } 39 | } 40 | } 41 | .sheet(isPresented: $showingEditSheet) { 42 | NavigationStack { 43 | FeedEditView(feed: feed) 44 | } 45 | } 46 | .refreshable { 47 | await articlesViewModel.refreshFeed(feed) 48 | } 49 | } 50 | } 51 | 52 | // MARK: - Helper Views 53 | 54 | /// 用于延迟加载视图内容的包装器 55 | private struct LazyView: View { 56 | // MARK: - Properties 57 | 58 | /// 构建视图的闭包 59 | private let build: () -> Content 60 | 61 | // MARK: - Initialization 62 | 63 | /// 创建一个延迟加载视图 64 | /// - Parameter build: 构建视图内容的闭包 65 | init(_ build: @autoclosure @escaping () -> Content) { 66 | self.build = build 67 | } 68 | 69 | // MARK: - Body 70 | 71 | var body: Content { 72 | build() 73 | } 74 | } -------------------------------------------------------------------------------- /RLLM/Views/Quotes/QuoteDetailView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct QuoteDetailView: View { 4 | let quote: Quote 5 | @Environment(\.openURL) private var openURL 6 | @State private var contentHeight: CGFloat = .zero 7 | @AppStorage("fontSize") private var fontSize: Double = 17 8 | 9 | var body: some View { 10 | ScrollView { 11 | VStack(alignment: .leading, spacing: 16) { 12 | if quote.isFullArticle { 13 | Label(NSLocalizedString("quote.full_article", comment: "Full article saved"), systemImage: "doc.text.fill") 14 | .font(.subheadline) 15 | .foregroundColor(.secondary) 16 | } 17 | 18 | RichTextView( 19 | html: quote.content, 20 | baseURL: URL(string: quote.articleURL), 21 | contentHeight: $contentHeight, 22 | fontSize: fontSize 23 | ) 24 | .frame(minHeight: 100) 25 | .frame(height: contentHeight > 0 ? contentHeight : nil) 26 | .animation(.easeInOut, value: contentHeight) 27 | 28 | Divider() 29 | 30 | VStack(alignment: .leading, spacing: 8) { 31 | Text(NSLocalizedString("quote.source_prefix", comment: "Source prefix") + quote.articleTitle) 32 | .font(.subheadline) 33 | 34 | Text(NSLocalizedString("quote.save_time_prefix", comment: "Save time prefix") + quote.savedDate.formatted()) 35 | .font(.caption) 36 | .foregroundColor(.secondary) 37 | } 38 | 39 | Button(NSLocalizedString("quote.view_original", comment: "View original")) { 40 | if let url = URL(string: quote.articleURL) { 41 | openURL(url) 42 | } 43 | } 44 | .buttonStyle(.bordered) 45 | } 46 | .padding() 47 | } 48 | .navigationTitle(NSLocalizedString("quote.detail", comment: "Quote detail")) 49 | } 50 | } -------------------------------------------------------------------------------- /RLLM/Services/StorageService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class StorageService { 4 | static let shared = StorageService() 5 | 6 | static let feedsKey = "saved_feeds" 7 | static let articlesKey = "saved_articles" 8 | private let defaults = UserDefaults.standard 9 | 10 | private init() {} 11 | 12 | // MARK: - Feeds 13 | func saveFeeds(_ feeds: [Feed]) { 14 | if let encoded = try? JSONEncoder().encode(feeds) { 15 | defaults.set(encoded, forKey: Self.feedsKey) 16 | } 17 | } 18 | 19 | func loadFeeds() -> [Feed] { 20 | guard let data = defaults.data(forKey: Self.feedsKey), 21 | let feeds = try? JSONDecoder().decode([Feed].self, from: data) else { 22 | return [] 23 | } 24 | return feeds 25 | } 26 | 27 | // MARK: - Articles 28 | func saveArticles(_ articles: [Article], for feedId: UUID) { 29 | if let encoded = try? JSONEncoder().encode(articles) { 30 | defaults.set(encoded, forKey: "\(Self.articlesKey)_\(feedId)") 31 | } 32 | } 33 | 34 | func loadArticles(for feedId: UUID) -> [Article] { 35 | guard let data = defaults.data(forKey: "\(Self.articlesKey)_\(feedId)"), 36 | let articles = try? JSONDecoder().decode([Article].self, from: data) else { 37 | return [] 38 | } 39 | return articles 40 | } 41 | 42 | func loadAllArticles() -> [UUID: [Article]] { 43 | var articlesMap: [UUID: [Article]] = [:] 44 | let feeds = loadFeeds() 45 | 46 | for feed in feeds { 47 | articlesMap[feed.id] = loadArticles(for: feed.id) 48 | } 49 | 50 | return articlesMap 51 | } 52 | 53 | func removeArticles(for feedId: UUID) { 54 | defaults.removeObject(forKey: "\(Self.articlesKey)_\(feedId)") 55 | } 56 | 57 | // MARK: - Data Cleanup 58 | 59 | /// 清理所有存储的数据 60 | func clearAllData() { 61 | // 删除所有feeds数据 62 | defaults.removeObject(forKey: Self.feedsKey) 63 | 64 | // 删除所有articles数据 65 | let feeds = loadFeeds() 66 | for feed in feeds { 67 | removeArticles(for: feed.id) 68 | } 69 | 70 | // 同步UserDefaults 71 | defaults.synchronize() 72 | } 73 | } -------------------------------------------------------------------------------- /RLLM/Models/CardTheme.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 表示卡片主题的数据模型 4 | /// 用于定义卡片的颜色和渐变样式 5 | struct CardTheme: Codable, Identifiable { 6 | // MARK: - Properties 7 | 8 | /// 主题的唯一标识符 9 | let id: Int 10 | 11 | /// 主题的名称 12 | let name: String 13 | 14 | /// 主题的颜色列表,使用十六进制颜色值 15 | let colors: [String] 16 | 17 | /// 是否使用渐变效果 18 | let isGradient: Bool 19 | 20 | // MARK: - Static Properties 21 | 22 | /// 预设的主题列表 23 | static let presets: [CardTheme] = [ 24 | // 渐变色主题 25 | CardTheme(id: 1, name: "极光紫", colors: ["#A18CD1", "#FBC2EB"], isGradient: true), 26 | CardTheme(id: 2, name: "清晨蓝", colors: ["#89f7fe", "#66a6ff"], isGradient: true), 27 | CardTheme(id: 3, name: "日落橙", colors: ["#fad0c4", "#ffd1ff"], isGradient: true), 28 | CardTheme(id: 4, name: "薄荷绿", colors: ["#84fab0", "#8fd3f4"], isGradient: true), 29 | 30 | // 纯色主题 31 | CardTheme(id: 5, name: "静谧蓝", colors: ["#5B7CF7"], isGradient: false), 32 | CardTheme(id: 6, name: "珊瑚粉", colors: ["#FF7B89"], isGradient: false), 33 | CardTheme(id: 7, name: "薄荷绿", colors: ["#69D0B3"], isGradient: false), 34 | CardTheme(id: 8, name: "暖阳黄", colors: ["#FFB344"], isGradient: false) 35 | ] 36 | } 37 | 38 | /// Color扩展,添加从十六进制字符串创建颜色的功能 39 | extension Color { 40 | /// 从十六进制字符串创建Color实例 41 | /// - Parameter hex: 十六进制颜色字符串,支持3位(RGB)、6位(RGB)和8位(ARGB)格式 42 | init(hex: String) { 43 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 44 | var int: UInt64 = 0 45 | Scanner(string: hex).scanHexInt64(&int) 46 | let a, r, g, b: UInt64 47 | switch hex.count { 48 | case 3: // RGB (12-bit) 49 | (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 50 | case 6: // RGB (24-bit) 51 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 52 | case 8: // ARGB (32-bit) 53 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 54 | default: 55 | (a, r, g, b) = (1, 1, 1, 0) 56 | } 57 | self.init( 58 | .sRGB, 59 | red: Double(r) / 255, 60 | green: Double(g) / 255, 61 | blue: Double(b) / 255, 62 | opacity: Double(a) / 255 63 | ) 64 | } 65 | } -------------------------------------------------------------------------------- /RLLM/Services/DailySummaryCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 每日总结缓存管理器 4 | class DailySummaryCache { 5 | /// 单例实例 6 | static let shared = DailySummaryCache() 7 | 8 | /// 缓存管理器 9 | private let cacheManager: CacheManager 10 | 11 | private init() { 12 | cacheManager = CacheManager(directoryName: "daily_summaries") 13 | } 14 | 15 | /// 每日总结缓存数据结构 16 | struct DailySummaryData: Codable { 17 | let summary: String 18 | let keyPoints: [String] 19 | let learningAdvice: String 20 | let readingTime: String 21 | let topTopics: [String] 22 | let topicCounts: [String: Int] 23 | let date: Date 24 | } 25 | 26 | /// 获取缓存的每日总结 27 | /// - Parameter date: 日期(将使用这个日期的开始时间作为键) 28 | /// - Returns: 缓存的每日总结数据 29 | func get(for date: Date) -> DailySummaryData? { 30 | let key = dateKey(for: date) 31 | do { 32 | let data = try cacheManager.read(for: key) 33 | let summary = try JSONDecoder().decode(DailySummaryData.self, from: data) 34 | 35 | // 检查是否是同一天的数据 36 | let calendar = Calendar.current 37 | if calendar.isDate(summary.date, inSameDayAs: date) { 38 | return summary 39 | } 40 | return nil 41 | } catch { 42 | print("读取每日总结缓存失败:\(error)") 43 | return nil 44 | } 45 | } 46 | 47 | /// 保存每日总结到缓存 48 | /// - Parameters: 49 | /// - summary: 总结数据 50 | /// - date: 日期 51 | func set(_ summary: DailySummaryData, for date: Date) { 52 | let key = dateKey(for: date) 53 | do { 54 | let data = try JSONEncoder().encode(summary) 55 | try cacheManager.write(data, for: key) 56 | } catch { 57 | print("保存每日总结缓存失败:\(error)") 58 | } 59 | } 60 | 61 | /// 检查是否有缓存 62 | /// - Parameter date: 日期 63 | /// - Returns: 是否有缓存 64 | func has(for date: Date) -> Bool { 65 | let key = dateKey(for: date) 66 | return cacheManager.has(for: key) 67 | } 68 | 69 | /// 清除所有缓存 70 | func clear() { 71 | cacheManager.clearAll() 72 | } 73 | 74 | /// 获取缓存统计信息 75 | /// - Returns: 缓存统计信息 76 | func getStats() -> CacheStats { 77 | cacheManager.getStats() 78 | } 79 | 80 | /// 生成日期对应的缓存键 81 | /// - Parameter date: 日期 82 | /// - Returns: 缓存键 83 | private func dateKey(for date: Date) -> String { 84 | let calendar = Calendar.current 85 | let components = calendar.dateComponents([.year, .month, .day], from: date) 86 | return "\(components.year ?? 0)-\(components.month ?? 0)-\(components.day ?? 0)" 87 | } 88 | } -------------------------------------------------------------------------------- /RLLM/Views/Articles/AddFeedView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AddFeedView: View { 4 | @Environment(\.dismiss) var dismiss 5 | @ObservedObject var viewModel: ArticlesViewModel 6 | @State private var url = "" 7 | @State private var customTitle = "" 8 | @State private var isLoading = false 9 | @State private var error: Error? 10 | @State private var showingError = false 11 | 12 | var body: some View { 13 | NavigationView { 14 | Form { 15 | Section { 16 | TextField(NSLocalizedString("add_feed.rss_url", comment: "RSS feed URL"), text: $url) 17 | .autocapitalization(.none) 18 | .disableAutocorrection(true) 19 | .keyboardType(.URL) 20 | 21 | TextField(NSLocalizedString("add_feed.feed_name", comment: "Feed name"), text: $customTitle) 22 | } 23 | 24 | Section { 25 | Button(action: addFeed) { 26 | if isLoading { 27 | ProgressView() 28 | .progressViewStyle(CircularProgressViewStyle()) 29 | } else { 30 | Text(NSLocalizedString("add_feed.add", comment: "Add feed button")) 31 | } 32 | } 33 | .disabled(url.isEmpty || isLoading) 34 | } 35 | } 36 | .navigationTitle(NSLocalizedString("add_feed.title", comment: "Add feed title")) 37 | .navigationBarItems(trailing: Button(NSLocalizedString("cancel", comment: "Cancel button")) { 38 | dismiss() 39 | }) 40 | .alert(NSLocalizedString("add_feed.error", comment: "Error alert title"), isPresented: $showingError) { 41 | Button(NSLocalizedString("add_feed.ok", comment: "OK button"), role: .cancel) {} 42 | } message: { 43 | Text(error?.localizedDescription ?? NSLocalizedString("add_feed.unknown_error", comment: "Unknown error message")) 44 | } 45 | } 46 | } 47 | 48 | private func addFeed() { 49 | isLoading = true 50 | 51 | Task { 52 | do { 53 | var feed = try await viewModel.validateFeed(url) 54 | if !customTitle.isEmpty { 55 | feed.title = customTitle 56 | } 57 | try await viewModel.addFeed(feed) 58 | dismiss() 59 | } catch { 60 | self.error = error 61 | showingError = true 62 | } 63 | isLoading = false 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /RLLM/Models/Article.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CryptoKit 3 | 4 | /// 文章的数据模型 5 | struct Article: Identifiable, Codable { 6 | // MARK: - Properties 7 | 8 | /// 文章的唯一标识符,基于URL生成的哈希值 9 | let id: UUID 10 | 11 | /// 文章的标题 12 | let title: String 13 | 14 | /// 文章的正文内容 15 | let content: String 16 | 17 | /// 文章的原始URL地址 18 | let url: String 19 | 20 | /// 文章的发布日期 21 | let publishDate: Date 22 | 23 | /// 文章所属的RSS源标题 24 | var feedTitle: String 25 | 26 | /// 文章所属的RSS源ID 27 | var feedId: UUID? 28 | 29 | /// 文章的作者信息 30 | let author: String? 31 | 32 | /// 文章是否已读 33 | var isRead: Bool 34 | 35 | /// 文章的AI生成摘要 36 | var summary: String? 37 | 38 | // MARK: - Initialization 39 | 40 | /// 创建一个新的Article实例 41 | /// - Parameters: 42 | /// - id: 唯一标识符,默认基于URL生成 43 | /// - title: 文章标题 44 | /// - content: 文章内容 45 | /// - url: 文章URL 46 | /// - publishDate: 发布日期 47 | /// - feedTitle: RSS源标题 48 | /// - feedId: RSS源ID 49 | /// - author: 作者信息 50 | /// - isRead: 是否已读 51 | /// - summary: AI生成的摘要 52 | init(id: UUID = UUID(), title: String, content: String, url: String, publishDate: Date, feedTitle: String, feedId: UUID? = nil, author: String? = nil, isRead: Bool = false, summary: String? = nil) { 53 | let urlData = url.data(using: .utf8) ?? Data() 54 | let hash = SHA256.hash(data: urlData) 55 | let hashData = Data(hash) 56 | self.id = UUID(uuid: (hashData.prefix(16) + hashData.prefix(16)).withUnsafeBytes { $0.load(as: uuid_t.self) }) 57 | 58 | self.title = title 59 | self.content = content 60 | self.url = url 61 | self.publishDate = publishDate 62 | self.feedTitle = feedTitle 63 | self.feedId = feedId 64 | self.author = author 65 | self.isRead = isRead 66 | self.summary = summary 67 | } 68 | 69 | // MARK: - Methods 70 | 71 | /// 更新Article的属性并返回新的实例 72 | /// - Parameters: 73 | /// - isRead: 新的已读状态,如果为nil则保持原值 74 | /// - summary: 新的摘要内容,如果为nil则保持原值 75 | /// - feedTitle: 新的RSS源标题,如果为nil则保持原值 76 | /// - feedId: 新的RSS源ID,如果为nil则保持原值 77 | /// - Returns: 更新后的Article实例 78 | func updating(isRead: Bool? = nil, summary: String? = nil, feedTitle: String? = nil, feedId: UUID? = nil) -> Article { 79 | Article( 80 | id: self.id, 81 | title: self.title, 82 | content: self.content, 83 | url: self.url, 84 | publishDate: self.publishDate, 85 | feedTitle: feedTitle ?? self.feedTitle, 86 | feedId: feedId ?? self.feedId, 87 | author: self.author, 88 | isRead: isRead ?? self.isRead, 89 | summary: summary ?? self.summary 90 | ) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /RLLM/Views/Articles/ArticlesListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ArticlesListView: View { 4 | @EnvironmentObject var articlesViewModel: ArticlesViewModel 5 | @State private var showAddFeedSheet = false 6 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 7 | 8 | private var columns: [GridItem] { 9 | if horizontalSizeClass == .regular { 10 | return [GridItem(.adaptive(minimum: 300, maximum: 400))] 11 | } else { 12 | return [ 13 | GridItem(.flexible()), 14 | GridItem(.flexible()) 15 | ] 16 | } 17 | } 18 | 19 | var body: some View { 20 | ScrollView { 21 | if articlesViewModel.feeds.isEmpty { 22 | ContentUnavailableView { 23 | Label("开始你的阅读之旅", systemImage: "doc.text.magnifyingglass") 24 | .font(.title2) 25 | } description: { 26 | Text("添加你感兴趣的RSS源,开始探索精彩内容") 27 | } actions: { 28 | Button(action: { showAddFeedSheet = true }) { 29 | Text("添加订阅源") 30 | .font(.headline) 31 | .foregroundColor(.white) 32 | .padding(.horizontal, 20) 33 | .padding(.vertical, 10) 34 | .background(Color.accentColor) 35 | .clipShape(RoundedRectangle(cornerRadius: 8)) 36 | } 37 | } 38 | .padding(.top, 100) 39 | } else { 40 | LazyVGrid(columns: columns, spacing: 16) { 41 | ForEach(articlesViewModel.feeds) { feed in 42 | NavigationLink(destination: ArticleListView(feed: feed)) { 43 | FeedCardView( 44 | feed: feed, 45 | articleCount: articlesViewModel.getArticleCount(for: feed), 46 | lastUpdateTime: articlesViewModel.getLastUpdateTime(for: feed), 47 | loadingState: articlesViewModel.feedLoadingStates[feed.id] ?? .idle 48 | ) 49 | } 50 | .buttonStyle(PlainButtonStyle()) 51 | } 52 | } 53 | .padding() 54 | } 55 | } 56 | .navigationTitle(NSLocalizedString("tab.articles", comment: "Articles tab")) 57 | .toolbar { 58 | ToolbarItem(placement: .navigationBarTrailing) { 59 | Button(action: { showAddFeedSheet = true }) { 60 | Image(systemName: "plus") 61 | } 62 | } 63 | } 64 | .sheet(isPresented: $showAddFeedSheet) { 65 | AddFeedView(viewModel: articlesViewModel) 66 | } 67 | .refreshable { 68 | print("\n=== ArticlesListView: Pull to refresh triggered ===") 69 | await articlesViewModel.refreshAllFeeds() 70 | print("=== ArticlesListView: Pull to refresh completed ===\n") 71 | } 72 | } 73 | } 74 | 75 | #Preview { 76 | NavigationView { 77 | ArticlesListView() 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python version updater 2 | update_version.py 3 | *.bak 4 | 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | 29 | ## Obj-C/Swift specific 30 | *.hmap 31 | 32 | ## App packaging 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # 43 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 44 | # Packages/ 45 | # Package.pins 46 | # Package.resolved 47 | # *.xcodeproj 48 | # 49 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 50 | # hence it is not needed unless you have added a package configuration file to your project 51 | .swiftpm/ 52 | 53 | .build/ 54 | 55 | # CocoaPods 56 | # 57 | # We recommend against adding the Pods directory to your .gitignore. However 58 | # you should judge for yourself, the pros and cons are mentioned at: 59 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 60 | # 61 | # Pods/ 62 | # 63 | # Add this line if you want to avoid checking in source code from the Xcode workspace 64 | # *.xcworkspace 65 | 66 | # Carthage 67 | # 68 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 69 | # Carthage/Checkouts 70 | 71 | Carthage/Build/ 72 | 73 | # Accio dependency management 74 | Dependencies/ 75 | .accio/ 76 | 77 | # fastlane 78 | # 79 | # It is recommended to not store the screenshots in the git repo. 80 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 81 | # For more information about the recommended setup visit: 82 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 83 | 84 | fastlane/report.xml 85 | fastlane/Preview.html 86 | fastlane/screenshots/**/*.png 87 | fastlane/test_output 88 | 89 | # Code Injection 90 | # 91 | # After new code Injection tools there's a generated folder /iOSInjectionProject 92 | # https://github.com/johnno1962/injectionforxcode 93 | 94 | iOSInjectionProject/ 95 | 96 | # OS generated files 97 | .DS_Store 98 | .DS_Store? 99 | ._* 100 | .Spotlight-V100 101 | .Trashes 102 | ehthumbs.db 103 | Thumbs.db 104 | 105 | # IDE 106 | .idea/ 107 | .vscode/ 108 | *.swp 109 | *.swo 110 | 111 | # Environment variables 112 | .env 113 | *.env 114 | .env.* 115 | !.env.example 116 | 117 | # Certificates 118 | *.cer 119 | *.certSigningRequest 120 | *.p12 121 | *.mobileprovision 122 | *.provisionprofile 123 | *.key 124 | 125 | # Sensitive Configuration 126 | Config.xcconfig 127 | */Config.xcconfig 128 | **/Config.xcconfig 129 | 130 | # Cache files 131 | .cache/ 132 | **/Cache/ 133 | **/Caches/ 134 | 135 | # Logs 136 | *.log 137 | logs/ 138 | .vscode/ 139 | .cursorrules 140 | *.bak/ 141 | *.bak 142 | 143 | # Test folders 144 | RLLMTests/ 145 | RLLMUITests/ -------------------------------------------------------------------------------- /RLLM/Views/Settings/ModelSelectionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ModelSelectionView: View { 4 | @Environment(\.dismiss) private var dismiss 5 | @StateObject private var viewModel = ModelSelectionViewModel() 6 | @State private var searchText = "" 7 | let config: LLMConfig 8 | let onModelSelected: (String) -> Void 9 | 10 | private var filteredModels: [Model] { 11 | if searchText.isEmpty { 12 | return viewModel.models 13 | } 14 | return viewModel.models.filter { model in 15 | model.name.localizedCaseInsensitiveContains(searchText) || 16 | (model.description?.localizedCaseInsensitiveContains(searchText) ?? false) || 17 | (model.provider?.localizedCaseInsensitiveContains(searchText) ?? false) 18 | } 19 | } 20 | 21 | var body: some View { 22 | ScrollView { 23 | LazyVStack(spacing: 16) { 24 | ForEach(filteredModels) { model in 25 | ModelCard( 26 | model: model, 27 | isSelected: model.id == config.model, 28 | onTap: { 29 | onModelSelected(model.id) 30 | dismiss() 31 | } 32 | ) 33 | .transition(.opacity) 34 | } 35 | } 36 | .padding(.horizontal) 37 | .padding(.top, 8) 38 | } 39 | .navigationTitle(NSLocalizedString("model.select", comment: "Select model")) 40 | .navigationBarTitleDisplayMode(.inline) 41 | .searchable(text: $searchText, prompt: NSLocalizedString("model.search", comment: "Search model"), suggestions: { 42 | if searchText.isEmpty { 43 | ForEach(viewModel.models.prefix(3)) { model in 44 | Text(model.name) 45 | .searchCompletion(model.name) 46 | } 47 | } 48 | }) 49 | .animation(.easeInOut, value: filteredModels) 50 | .overlay { 51 | if viewModel.isLoading { 52 | ProgressView() 53 | .scaleEffect(1.5) 54 | .frame(maxWidth: .infinity, maxHeight: .infinity) 55 | .background(Color(.systemBackground).opacity(0.8)) 56 | } 57 | } 58 | .alert(NSLocalizedString("model.error", comment: "Error"), isPresented: $viewModel.showError) { 59 | Button(NSLocalizedString("model.ok", comment: "OK"), role: .cancel) {} 60 | } message: { 61 | Text(viewModel.errorMessage ?? NSLocalizedString("model.unknown_error", comment: "Unknown error")) 62 | } 63 | .task { 64 | await viewModel.fetchModels(config: config) 65 | } 66 | } 67 | } 68 | 69 | class ModelSelectionViewModel: ObservableObject { 70 | @Published var models: [Model] = [] 71 | @Published var isLoading = false 72 | @Published var showError = false 73 | @Published var errorMessage: String? 74 | 75 | @MainActor 76 | func fetchModels(config: LLMConfig) async { 77 | isLoading = true 78 | defer { isLoading = false } 79 | 80 | do { 81 | let models = try await LLMService.shared.fetchAvailableModels(config: config) 82 | self.models = models 83 | } catch { 84 | self.errorMessage = error.localizedDescription 85 | self.showError = true 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /RLLM/Views/Quotes/QuoteRowView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct QuoteRowView: View { 4 | let quote: Quote 5 | @Environment(\.colorScheme) private var colorScheme 6 | 7 | private var previewContent: String { 8 | quote.content.removingHTMLTags() 9 | } 10 | 11 | var body: some View { 12 | VStack(alignment: .leading, spacing: 12) { 13 | // 内容区域 14 | VStack(alignment: .leading, spacing: 8) { 15 | if quote.isFullArticle { 16 | Text(NSLocalizedString("quote.full_article", comment: "Full article saved")) 17 | .font(.caption) 18 | .foregroundColor(.secondary) 19 | .padding(.vertical, 4) 20 | .padding(.horizontal, 10) 21 | .background( 22 | Capsule() 23 | .fill(colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)) 24 | .overlay( 25 | Capsule() 26 | .stroke( 27 | Color(.separator).opacity(colorScheme == .dark ? 0.3 : 0), 28 | lineWidth: colorScheme == .dark ? 0.5 : 0 29 | ) 30 | ) 31 | ) 32 | } 33 | 34 | Text(previewContent) 35 | .font(.body) 36 | .lineLimit(4) 37 | .multilineTextAlignment(.leading) 38 | } 39 | 40 | // 底部信息区域 41 | HStack(spacing: 8) { 42 | // 文章标题 43 | HStack(spacing: 4) { 44 | Image(systemName: "link") 45 | .font(.caption) 46 | .foregroundColor(.secondary) 47 | Text(quote.articleTitle) 48 | .font(.caption) 49 | .foregroundColor(.secondary) 50 | .lineLimit(1) 51 | } 52 | 53 | Spacer() 54 | 55 | // 保存时间 56 | HStack(spacing: 4) { 57 | Image(systemName: "clock") 58 | .font(.caption) 59 | .foregroundColor(.secondary) 60 | Text(quote.savedDate.formatted(.relative(presentation: .named))) 61 | .font(.caption) 62 | .foregroundColor(.secondary) 63 | } 64 | } 65 | } 66 | .padding(.vertical, 12) 67 | .padding(.horizontal, 16) 68 | .background( 69 | RoundedRectangle(cornerRadius: 12) 70 | .fill(colorScheme == .dark ? Color(.systemGray6) : Color(.systemBackground)) 71 | .shadow( 72 | color: Color.black.opacity(colorScheme == .dark ? 0.2 : 0.05), 73 | radius: colorScheme == .dark ? 3 : 2, 74 | x: 0, 75 | y: colorScheme == .dark ? 2 : 1 76 | ) 77 | .overlay( 78 | RoundedRectangle(cornerRadius: 12) 79 | .stroke( 80 | Color(.separator).opacity(colorScheme == .dark ? 0.3 : 0), 81 | lineWidth: colorScheme == .dark ? 1 : 0 82 | ) 83 | ) 84 | ) 85 | } 86 | } -------------------------------------------------------------------------------- /RLLM/Models/ToastManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import Toast 4 | 5 | /// Toast管理器 6 | final class ToastManager: ObservableObject { 7 | static let shared = ToastManager() 8 | private init() {} 9 | 10 | /// 显示一个Toast 11 | /// - Parameters: 12 | /// - type: Toast类型 13 | /// - title: 标题(可选,如果为nil则使用默认标题) 14 | /// - message: 消息内容(可选) 15 | /// - duration: 显示时长(默认2秒) 16 | func show(type: ToastType, title: String? = nil, message: String? = nil, duration: TimeInterval = 2.0) { 17 | let config = ToastConfiguration( 18 | direction: .top, 19 | dismissBy: [.time(time: duration), .swipe(direction: .natural)], 20 | animationTime: 0.2 21 | ) 22 | 23 | let displayTitle = title ?? type.localizedTitle 24 | 25 | // 创建带图标的toast 26 | if let image = UIImage(systemName: type.systemImage)?.withTintColor(type.uiColor, renderingMode: .alwaysOriginal) { 27 | Toast.default( 28 | image: image, 29 | title: displayTitle, 30 | subtitle: message, 31 | config: config 32 | ).show() 33 | } else { 34 | // 如果无法创建图标,则使用纯文本toast 35 | Toast.text( 36 | displayTitle, 37 | subtitle: message, 38 | config: config 39 | ).show() 40 | } 41 | } 42 | 43 | /// 显示成功Toast 44 | func showSuccess(_ title: String? = nil, message: String? = nil) { 45 | show(type: .success, title: title, message: message) 46 | } 47 | 48 | /// 显示错误Toast 49 | func showError(_ title: String? = nil, message: String? = nil) { 50 | show(type: .error, title: title, message: message) 51 | } 52 | 53 | /// 显示警告Toast 54 | func showWarning(_ title: String? = nil, message: String? = nil) { 55 | show(type: .warning, title: title, message: message) 56 | } 57 | 58 | /// 显示信息Toast 59 | func showInfo(_ title: String? = nil, message: String? = nil) { 60 | show(type: .info, title: title, message: message) 61 | } 62 | } 63 | 64 | /// Toast消息的类型 65 | enum ToastType { 66 | case success 67 | case error 68 | case warning 69 | case info 70 | 71 | /// 获取对应的系统图标名称 72 | var systemImage: String { 73 | switch self { 74 | case .success: 75 | return "checkmark.circle.fill" 76 | case .error: 77 | return "xmark.circle.fill" 78 | case .warning: 79 | return "exclamationmark.triangle.fill" 80 | case .info: 81 | return "info.circle.fill" 82 | } 83 | } 84 | 85 | /// 获取对应的UIColor 86 | var uiColor: UIColor { 87 | switch self { 88 | case .success: 89 | return .systemGreen 90 | case .error: 91 | return .systemRed 92 | case .warning: 93 | return .systemOrange 94 | case .info: 95 | return .systemBlue 96 | } 97 | } 98 | 99 | /// 获取本地化的标题 100 | var localizedTitle: String { 101 | switch self { 102 | case .success: 103 | return NSLocalizedString("toast.success", comment: "Toast success") 104 | case .error: 105 | return NSLocalizedString("toast.error", comment: "Toast error") 106 | case .warning: 107 | return NSLocalizedString("toast.warning", comment: "Toast warning") 108 | case .info: 109 | return NSLocalizedString("toast.info", comment: "Toast info") 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: iOS Build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | name: Build and Archive iOS App 12 | runs-on: macos-14 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Select Xcode 19 | run: sudo xcode-select -s /Applications/Xcode_16.1.app 20 | 21 | - name: Set Build Number 22 | id: set_build_number 23 | run: | 24 | buildNumber=${{ github.run_number }} 25 | echo "Setting build number to $buildNumber" 26 | echo "BUILD_NUMBER=$buildNumber" >> $GITHUB_ENV 27 | /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "RLLM/Info.plist" 28 | echo "Current Info.plist content:" 29 | /usr/libexec/PlistBuddy -c "Print" "RLLM/Info.plist" 30 | 31 | - name: Build Configuration 32 | run: | 33 | echo "📱 Building RLLM iOS app" 34 | echo "Build number: ${{ env.BUILD_NUMBER }}" 35 | echo "Xcode version:" 36 | xcodebuild -version 37 | echo "Swift version:" 38 | swift --version 39 | 40 | - name: Install Dependencies 41 | run: | 42 | if [ -f "Package.swift" ]; then 43 | swift package resolve 44 | fi 45 | 46 | - name: Create Export Options Plist 47 | run: | 48 | cat > exportOptions.plist << EOF 49 | 50 | 51 | 52 | 53 | method 54 | ad-hoc 55 | compileBitcode 56 | 57 | signingStyle 58 | automatic 59 | stripSwiftSymbols 60 | 61 | thinning 62 | <none> 63 | 64 | 65 | EOF 66 | 67 | - name: Build for Archive 68 | run: | 69 | xcodebuild clean build -scheme RLLM \ 70 | -configuration Release \ 71 | -sdk iphoneos \ 72 | CURRENT_PROJECT_VERSION=${{ env.BUILD_NUMBER }} \ 73 | MARKETING_VERSION=0.4.3 \ 74 | CODE_SIGN_IDENTITY="" \ 75 | CODE_SIGNING_REQUIRED=NO \ 76 | CODE_SIGNING_ALLOWED=NO \ 77 | ENABLE_BITCODE=NO \ 78 | ONLY_ACTIVE_ARCH=NO 79 | 80 | - name: Create Archive 81 | run: | 82 | xcodebuild archive \ 83 | -scheme RLLM \ 84 | -configuration Release \ 85 | -sdk iphoneos \ 86 | -archivePath $RUNNER_TEMP/RLLM.xcarchive \ 87 | CURRENT_PROJECT_VERSION=${{ env.BUILD_NUMBER }} \ 88 | MARKETING_VERSION=0.4.3 \ 89 | CODE_SIGN_IDENTITY="" \ 90 | CODE_SIGNING_REQUIRED=NO \ 91 | CODE_SIGNING_ALLOWED=NO \ 92 | ENABLE_BITCODE=NO \ 93 | ONLY_ACTIVE_ARCH=NO 94 | 95 | - name: Create IPA 96 | run: | 97 | cd $RUNNER_TEMP/RLLM.xcarchive/Products/Applications 98 | mkdir Payload 99 | cp -r RLLM.app Payload/ 100 | zip -r RLLM.ipa Payload 101 | 102 | - name: Upload IPA 103 | uses: actions/upload-artifact@v4 104 | with: 105 | name: unsigned-ipa 106 | path: ${{ runner.temp }}/RLLM.xcarchive/Products/Applications/RLLM.ipa -------------------------------------------------------------------------------- /RLLM/Services/CleanupService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 数据清理服务 4 | class CleanupService { 5 | static let shared = CleanupService() 6 | 7 | private let coreDataManager = CoreDataManager.shared 8 | private let defaults = UserDefaults.standard 9 | 10 | private let lastCleanupKey = "last_cleanup_date" 11 | private let cleanupIntervalKey = "cleanup_interval_days" 12 | private let articleAgeKey = "article_age_days" 13 | private let summaryAgeKey = "summary_age_days" 14 | private let maxArticlesPerFeedKey = "max_articles_per_feed" 15 | 16 | private init() { 17 | // 设置默认值 18 | if defaults.object(forKey: cleanupIntervalKey) == nil { 19 | defaults.set(7, forKey: cleanupIntervalKey) // 默认每7天清理一次 20 | } 21 | if defaults.object(forKey: articleAgeKey) == nil { 22 | defaults.set(90, forKey: articleAgeKey) // 默认保留90天的文章 23 | } 24 | if defaults.object(forKey: summaryAgeKey) == nil { 25 | defaults.set(30, forKey: summaryAgeKey) // 默认保留30天的每日总结 26 | } 27 | if defaults.object(forKey: maxArticlesPerFeedKey) == nil { 28 | defaults.set(100, forKey: maxArticlesPerFeedKey) // 默认每个Feed最多保留100篇文章 29 | } 30 | } 31 | 32 | /// 检查是否需要执行清理 33 | var needsCleanup: Bool { 34 | let now = Date() 35 | let lastCleanup = defaults.object(forKey: lastCleanupKey) as? Date ?? .distantPast 36 | let interval = TimeInterval(defaults.integer(forKey: cleanupIntervalKey) * 24 * 60 * 60) 37 | return now.timeIntervalSince(lastCleanup) >= interval 38 | } 39 | 40 | /// 执行清理操作 41 | func performCleanupIfNeeded() { 42 | guard needsCleanup else { return } 43 | 44 | let articleAge = defaults.integer(forKey: articleAgeKey) 45 | let summaryAge = defaults.integer(forKey: summaryAgeKey) 46 | let maxArticlesPerFeed = defaults.integer(forKey: maxArticlesPerFeedKey) 47 | 48 | coreDataManager.performCleanup( 49 | articleAge: articleAge, 50 | summaryAge: summaryAge, 51 | maxArticlesPerFeed: maxArticlesPerFeed 52 | ) 53 | 54 | // 更新最后清理时间 55 | defaults.set(Date(), forKey: lastCleanupKey) 56 | } 57 | 58 | /// 更新清理配置 59 | /// - Parameters: 60 | /// - cleanupInterval: 清理间隔(天) 61 | /// - articleAge: 文章保留时间(天) 62 | /// - summaryAge: 每日总结保留时间(天) 63 | /// - maxArticlesPerFeed: 每个Feed保留的最大文章数量 64 | func updateConfig( 65 | cleanupInterval: Int? = nil, 66 | articleAge: Int? = nil, 67 | summaryAge: Int? = nil, 68 | maxArticlesPerFeed: Int? = nil 69 | ) { 70 | if let interval = cleanupInterval { 71 | defaults.set(interval, forKey: cleanupIntervalKey) 72 | } 73 | if let age = articleAge { 74 | defaults.set(age, forKey: articleAgeKey) 75 | } 76 | if let age = summaryAge { 77 | defaults.set(age, forKey: summaryAgeKey) 78 | } 79 | if let max = maxArticlesPerFeed { 80 | defaults.set(max, forKey: maxArticlesPerFeedKey) 81 | } 82 | } 83 | 84 | /// 获取当前配置 85 | /// - Returns: 清理配置信息 86 | func getConfig() -> (cleanupInterval: Int, articleAge: Int, summaryAge: Int, maxArticlesPerFeed: Int) { 87 | return ( 88 | cleanupInterval: defaults.integer(forKey: cleanupIntervalKey), 89 | articleAge: defaults.integer(forKey: articleAgeKey), 90 | summaryAge: defaults.integer(forKey: summaryAgeKey), 91 | maxArticlesPerFeed: defaults.integer(forKey: maxArticlesPerFeedKey) 92 | ) 93 | } 94 | 95 | /// 强制执行清理 96 | func forceCleanup() { 97 | performCleanupIfNeeded() 98 | } 99 | } -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | RLLM 图标 4 | 5 | # RLLM 6 | 7 | 🌟 由 LLM 驱动的 RSS 阅读器 8 | 9 | 🌐 项目主页 10 | 11 | [English](README.md) | [中文](README_CN.md) 12 | 13 | [![GitHub stars](https://img.shields.io/github/stars/DanielZhangyc/RLLM.svg?style=social)](https://github.com/DanielZhangyc/RLLM/stargazers) 14 | [![构建状态](https://github.com/DanielZhangyc/RLLM/actions/workflows/swift.yml/badge.svg)](https://github.com/DanielZhangyc/RLLM/actions/workflows/swift.yml) 15 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 16 | [![Swift](https://img.shields.io/badge/Swift-5.0-orange.svg)](https://swift.org) 17 | [![Platform](https://img.shields.io/badge/platform-iOS-lightgrey.svg)](https://www.apple.com/ios/) 18 | 19 |
20 | 21 | # 📖 RLLM - LLM 驱动的 RSS 阅读器 22 | 23 | RLLM 是一个由大语言模型驱动的创新型 RSS 阅读器,提供智能内容分析和摘要功能。 24 | 25 | 26 | 27 | - [功能特性](#功能特性) 28 | - [应用截图](#应用截图) 29 | - [安装方式](#安装方式) 30 | - [开发](#开发) 31 | - [参与贡献](#参与贡献) 32 | - [FAQ](#FAQ) 33 | - [开源协议](#License) 34 | 35 | 36 | 37 | ## ✨ 功能特性 38 | 39 | ### RSS 阅读 40 | - ✅ 支持 RSS 1.0、2.0 和 Atom 订阅源 41 | - ✅ 文章/语段阅读与收藏 42 | 43 | ### AI 功能 44 | - ✅ AI 文章摘要生成 45 | - ✅ AI 文章洞察分析 46 | - ✅ 每日阅读 AI 总结 47 | - ✅ 集成 Anthropic、Deepseek 和 OpenAI 48 | 49 | ### TODO 50 | - 📝 完善收藏管理 51 | - 📝 收藏 AI 总结 52 | - 📝 近期阅读分析 53 | - 📝 近期阅读趋势/标签 54 | 55 | 56 | ## 📱 应用截图 57 | 58 |
59 | 主页 60 | AI 洞察 61 | 语段收藏 62 | 每日总结 63 |
64 | 65 | 66 | ## 📥 安装方式 67 | 68 | ### 方式一:从源码构建 69 | 70 | 请参考[开发](#开发)部分了解从源码构建的详细步骤。 71 | 72 | ### 方式二:安装IPA文件 73 | 74 | 1. 从[GitHub Actions](https://github.com/DanielZhangyc/RLLM/actions)下载最新的未签名IPA文件(最新成功构建) 75 | 2. 使用以下方式之一签名并安装IPA文件: 76 | 77 | #### 使用签名工具 78 | - [AltStore](https://altstore.io) - 流行的侧载工具,支持自动重签名 79 | - [Sideloadly](https://sideloadly.io) - 跨平台侧载工具 80 | - [ESign](https://esign.yyyue.xyz) - 设备端签名工具 81 | 82 | #### 使用TrollStore(无需签名) 83 | - [TrollStore](https://github.com/opa334/TrollStore) - 支持iOS 14.0-15.4.1、15.5beta4和16.0-16.6.1的永久应用安装工具 84 | 85 | #### 使用其他方式 86 | - [Scarlet](https://usescarlet.com) - 设备端应用安装器 87 | - 使用您的Apple开发者账号和Xcode 88 | - 企业证书(如果您有权限) 89 | 90 | 注意:IPA文件未经签名,除非在受支持的iOS版本上使用TrollStore,否则需要先签名才能安装到您的设备上。 91 | 92 | 93 | ## 👨‍💻 开发 94 | 95 | ### 环境要求 96 | 97 | - Xcode 15.0+ 98 | - iOS 17.0+ 99 | - Swift 5.0+ 100 | 101 | ### 依赖库 102 | 103 | - [FeedKit](https://github.com/nmdias/FeedKit) - RSS 和 Atom 订阅源解析器 104 | - [Alamofire](https://github.com/Alamofire/Alamofire) - HTTP 网络请求库 105 | 106 | ### 开始开发 107 | 108 | 1. 克隆仓库 109 | ```bash 110 | git clone https://github.com/DanielZhangyc/RLLM.git 111 | cd RLLM 112 | ``` 113 | 114 | 2. 在 Xcode 中打开项目 115 | ```bash 116 | open RLLM.xcodeproj 117 | ``` 118 | 119 | 3. 在 Xcode 中构建和运行项目 120 | 121 | 122 | ## 🤝 参与贡献 123 | 124 | 欢迎你的PR :) 125 | 126 | 1. Fork 本仓库 127 | 2. 创建新分支 (`git checkout -b feature/amazing-feature`) 128 | 3. 提交修改 129 | 4. 提交代码 (`git commit -m 'Write something here'`) 130 | 5. 推送到分支 (`git push origin feature/amazing-feature`) 131 | 6. 提交 Pull Request 132 | 133 | 需要帮助?欢迎: 134 | - 提交 Issue 135 | - 发起讨论 136 | 137 | 138 | ## ❓ FAQ 139 | 140 | ### RLLM 这个名字怎么来的? 141 | 142 | RLLM = RSS + LLM,代表软件希望使用 LLM 的能力来增强 RSS 阅读体验。 143 | 144 | ### 需要自己提供 API 密钥吗? 145 | 146 | 是的,您需要为想要使用的 LLM 服务提供自己的 API 密钥。这些可以在应用的设置中配置。 147 | 148 | 149 | ## 📄 License 150 | 151 | 本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件 152 | 153 | [![Star History Chart](https://api.star-history.com/svg?repos=DanielZhangyc/RLLM&type=Date)](https://star-history.com/#DanielZhangyc/RLLM&Date) -------------------------------------------------------------------------------- /RLLM/Views/Components/ModelCard.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ModelCard: View { 4 | let model: Model 5 | let isSelected: Bool 6 | let onTap: () -> Void 7 | 8 | @State private var isPressed = false 9 | @Environment(\.colorScheme) private var colorScheme 10 | 11 | var body: some View { 12 | Button(action: { 13 | withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { 14 | isPressed = true 15 | } 16 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 17 | withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { 18 | isPressed = false 19 | } 20 | HapticManager.shared.selection() 21 | onTap() 22 | } 23 | }) { 24 | VStack(alignment: .leading, spacing: 12) { 25 | HStack(alignment: .top) { 26 | VStack(alignment: .leading, spacing: 4) { 27 | Text(model.name) 28 | .font(.headline) 29 | .foregroundColor(.primary) 30 | 31 | if let description = model.description { 32 | Text(description) 33 | .font(.subheadline) 34 | .foregroundColor(.secondary) 35 | .lineLimit(2) 36 | } 37 | 38 | if model.isThinkingModel { 39 | Text(NSLocalizedString("model.thinking_warning", comment: "Model thinking warning")) 40 | .font(.caption) 41 | .foregroundColor(.orange) 42 | .padding(.top, 4) 43 | } 44 | } 45 | 46 | Spacer() 47 | 48 | if isSelected { 49 | Image(systemName: "checkmark.circle.fill") 50 | .foregroundColor(.blue) 51 | .imageScale(.large) 52 | } 53 | } 54 | 55 | HStack(spacing: 12) { 56 | if let provider = model.provider { 57 | Label(provider, systemImage: "server.rack") 58 | .font(.caption) 59 | .foregroundColor(.secondary) 60 | .padding(.vertical, 4) 61 | .padding(.horizontal, 8) 62 | .background(Color(.systemGray6)) 63 | .cornerRadius(8) 64 | } 65 | 66 | if let contextLength = model.contextLength { 67 | Label("\(contextLength) tokens", systemImage: "text.word.spacing") 68 | .font(.caption) 69 | .foregroundColor(.secondary) 70 | .padding(.vertical, 4) 71 | .padding(.horizontal, 8) 72 | .background(Color(.systemGray6)) 73 | .cornerRadius(8) 74 | } 75 | } 76 | } 77 | .padding(.vertical, 16) 78 | .padding(.horizontal, 20) 79 | .frame(maxWidth: .infinity, alignment: .leading) 80 | .background( 81 | RoundedRectangle(cornerRadius: 16) 82 | .fill(Color(.systemBackground)) 83 | ) 84 | .overlay( 85 | RoundedRectangle(cornerRadius: 16) 86 | .stroke( 87 | isSelected ? Color.blue : 88 | Color.primary.opacity(colorScheme == .dark ? 0.1 : 0.05), 89 | lineWidth: isSelected ? 2 : 1 90 | ) 91 | ) 92 | .shadow( 93 | color: Color.black.opacity(colorScheme == .dark ? 0.2 : 0.05), 94 | radius: 8, 95 | x: 0, 96 | y: isPressed ? 2 : 4 97 | ) 98 | .scaleEffect(isPressed ? 0.98 : 1) 99 | } 100 | .buttonStyle(PlainButtonStyle()) 101 | } 102 | } -------------------------------------------------------------------------------- /RLLM/Services/LLMConnections.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LLMConnection { 4 | let endpoint: String 5 | let headers: [String: String] 6 | let body: [String: Any] 7 | } 8 | 9 | class LLMConnectionManager { 10 | static func getConnection(for provider: LLMProvider, config: LLMConfig, prompt: String, temperature: Double) -> LLMConnection { 11 | var endpoint: String 12 | var headers: [String: String] = [ 13 | "Content-Type": "application/json" 14 | ] 15 | var body: [String: Any] 16 | 17 | switch provider { 18 | case .openAI: 19 | endpoint = "\(config.baseURL)/chat/completions" 20 | headers["Authorization"] = "Bearer \(config.apiKey)" 21 | body = [ 22 | "model": config.model, 23 | "messages": [ 24 | ["role": "system", "content": "你是一个专业的文章分析助手,善于深入分析文章并提供独到见解。你的输出必须严格遵循指定的格式。"], 25 | ["role": "user", "content": prompt] 26 | ], 27 | "temperature": temperature, 28 | "max_tokens": config.maxTokens 29 | ] 30 | 31 | case .anthropic: 32 | endpoint = "\(config.baseURL)/v1/messages" 33 | headers["x-api-key"] = config.apiKey 34 | headers["anthropic-version"] = "2023-06-01" 35 | body = [ 36 | "model": config.model, 37 | "max_tokens": config.maxTokens, 38 | "temperature": temperature, 39 | "messages": [["role": "user", "content": prompt]] 40 | ] 41 | 42 | case .deepseek: 43 | endpoint = "\(config.baseURL)/chat/completions" 44 | headers["Authorization"] = "Bearer \(config.apiKey)" 45 | body = [ 46 | "model": config.model, 47 | "messages": [ 48 | ["role": "system", "content": "你是一个专业的文章分析助手,善于深入分析文章并提供独到见解。你的输出必须严格遵循指定的格式。"], 49 | ["role": "user", "content": prompt] 50 | ], 51 | "temperature": temperature, 52 | "max_tokens": config.maxTokens 53 | ] 54 | 55 | case .custom: 56 | endpoint = "\(config.baseURL)/chat/completions" 57 | headers["Authorization"] = "Bearer \(config.apiKey)" 58 | body = [ 59 | "model": config.model, 60 | "messages": [ 61 | ["role": "system", "content": "你是一个专业的文章分析助手,善于深入分析文章并提供独到见解。你的输出必须严格遵循指定的格式。"], 62 | ["role": "user", "content": prompt] 63 | ], 64 | "temperature": temperature, 65 | "max_tokens": config.maxTokens 66 | ] 67 | } 68 | 69 | return LLMConnection(endpoint: endpoint, headers: headers, body: body) 70 | } 71 | 72 | static func getProviders() -> [LLMProvider] { 73 | // 返回排序后的提供者列表,自定义始终在底部,其他选项按字母顺序排序 74 | let allCases = LLMProvider.allCases.filter { $0 != .custom } 75 | let sorted = allCases.sorted { $0.rawValue.localizedStandardCompare($1.rawValue) == .orderedAscending } 76 | return sorted + [.custom] 77 | } 78 | 79 | static func getModelEndpoint(for provider: LLMProvider, config: LLMConfig) -> (endpoint: String, headers: [String: String]) { 80 | var endpoint: String 81 | var headers: [String: String] = [ 82 | "Content-Type": "application/json" 83 | ] 84 | 85 | switch provider { 86 | case .openAI: 87 | endpoint = "\(config.baseURL)/models" 88 | headers["Authorization"] = "Bearer \(config.apiKey)" 89 | case .anthropic: 90 | endpoint = "\(config.baseURL)/v1/models" 91 | headers["x-api-key"] = config.apiKey 92 | headers["anthropic-version"] = "2023-06-01" 93 | case .deepseek: 94 | endpoint = "\(config.baseURL)/models" 95 | headers["Authorization"] = "Bearer \(config.apiKey)" 96 | case .custom: 97 | endpoint = "\(config.baseURL)/models" 98 | headers["Authorization"] = "Bearer \(config.apiKey)" 99 | headers["HTTP-Referer"] = "https://github.com/CaffeineShawn/RLLM" 100 | headers["X-Title"] = "RLLM" 101 | } 102 | 103 | return (endpoint, headers) 104 | } 105 | } -------------------------------------------------------------------------------- /RLLM/Services/DataMigrationService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | import os 4 | 5 | /// 数据迁移服务 6 | class DataMigrationService { 7 | static let shared = DataMigrationService() 8 | 9 | private let defaults = UserDefaults.standard 10 | private let migrationKey = "data_migration_completed" 11 | private let coreDataManager = CoreDataManager.shared 12 | private let storageService = StorageService.shared 13 | private let quotesViewModel = QuotesViewModel.shared 14 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Migration") 15 | 16 | private init() {} 17 | 18 | /// 检查是否需要执行迁移 19 | var needsMigration: Bool { 20 | !defaults.bool(forKey: migrationKey) 21 | } 22 | 23 | /// 执行数据迁移 24 | func performMigration() { 25 | guard needsMigration else { 26 | logger.info("No migration needed") 27 | return 28 | } 29 | 30 | logger.info("Starting data migration...") 31 | 32 | // 迁移Feed数据 33 | let feeds = storageService.loadFeeds() 34 | logger.info("Found \(feeds.count) feeds to migrate") 35 | 36 | for feed in feeds { 37 | logger.info("Migrating feed: \(feed.title)") 38 | _ = coreDataManager.createOrUpdateFeed(feed) 39 | 40 | // 迁移该Feed下的所有文章 41 | let articles = storageService.loadArticles(for: feed.id) 42 | logger.info("Found \(articles.count) articles for feed: \(feed.title)") 43 | 44 | for article in articles { 45 | let _ = coreDataManager.createOrUpdateArticle(article, feedId: feed.id) 46 | 47 | // 迁移该文章的收藏语段 48 | let quotes = quotesViewModel.quotes.filter { $0.articleURL == article.url } 49 | if !quotes.isEmpty { 50 | logger.info("Migrating \(quotes.count) quotes for article: \(article.title)") 51 | } 52 | 53 | for quote in quotes { 54 | _ = coreDataManager.createOrUpdateQuote(quote, articleId: article.id) 55 | } 56 | } 57 | 58 | // 迁移完成后删除UserDefaults中的文章数据 59 | storageService.removeArticles(for: feed.id) 60 | logger.info("Removed old articles data for feed: \(feed.title)") 61 | } 62 | 63 | // 迁移阅读历史数据 64 | migrateReadingHistory() 65 | 66 | // 标记迁移完成 67 | defaults.set(true, forKey: migrationKey) 68 | 69 | // 清理旧数据 70 | defaults.removeObject(forKey: StorageService.feedsKey) 71 | defaults.removeObject(forKey: "saved_quotes") // 清理旧的quotes数据 72 | defaults.removeObject(forKey: "reading_stats") // 清理旧的阅读统计数据 73 | defaults.removeObject(forKey: "reading_records") // 清理旧的阅读记录数据 74 | storageService.clearAllData() 75 | 76 | logger.info("Migration completed successfully") 77 | } 78 | 79 | /// 迁移阅读历史数据 80 | private func migrateReadingHistory() { 81 | logger.info("Starting reading history migration...") 82 | 83 | // 迁移阅读统计数据 84 | if let statsData = defaults.data(forKey: "reading_stats"), 85 | let stats = try? JSONDecoder().decode([String: ReadingStats].self, from: statsData) { 86 | logger.info("Found \(stats.count) reading stats to migrate") 87 | 88 | for (dateString, stat) in stats { 89 | guard let date = DateFormatter.yyyyMMdd.date(from: dateString) else { continue } 90 | let readingStats = ReadingStats( 91 | totalReadingTime: stat.totalReadingTime, 92 | articleCount: stat.articleCount, 93 | date: date, 94 | actualReadingDays: stat.actualReadingDays 95 | ) 96 | _ = coreDataManager.createOrUpdateReadingStats(readingStats) 97 | } 98 | } 99 | 100 | // 迁移阅读记录数据 101 | if let recordsData = defaults.data(forKey: "reading_records"), 102 | let records = try? JSONDecoder().decode([ReadingRecord].self, from: recordsData) { 103 | logger.info("Found \(records.count) reading records to migrate") 104 | 105 | for record in records { 106 | if let articleId = UUID(uuidString: record.articleId) { 107 | _ = coreDataManager.createOrUpdateReadingRecord(record, articleId: articleId) 108 | } 109 | } 110 | } 111 | 112 | logger.info("Reading history migration completed") 113 | } 114 | 115 | /// 重置迁移状态(用于测试) 116 | func resetMigrationStatus() { 117 | defaults.set(false, forKey: migrationKey) 118 | logger.info("Migration status reset") 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /RLLM/App/RLLMApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Alamofire 3 | import os 4 | 5 | @main 6 | struct RLLMApp: App { 7 | @StateObject private var articlesViewModel = ArticlesViewModel() 8 | @StateObject private var llmSettingsViewModel = LLMSettingsViewModel() 9 | private let logger = Logger(subsystem: "xy0v0.RLLM", category: "URLScheme") 10 | @State private var showError = false 11 | @State private var errorMessage = "" 12 | 13 | init() { 14 | // 检查是否有旧数据需要迁移 15 | let storageService = StorageService.shared 16 | let oldFeeds = storageService.loadFeeds() 17 | 18 | if !oldFeeds.isEmpty { 19 | // 只有在存在旧数据时才执行迁移 20 | DataMigrationService.shared.resetMigrationStatus() 21 | DataMigrationService.shared.performMigration() 22 | } 23 | 24 | // 检查并执行数据清理 25 | CleanupService.shared.performCleanupIfNeeded() 26 | // 在应用启动时主动请求网络权限 27 | requestNetworkPermission() 28 | } 29 | 30 | var body: some Scene { 31 | WindowGroup { 32 | ContentView() 33 | .environmentObject(articlesViewModel) 34 | .environmentObject(llmSettingsViewModel) 35 | .onOpenURL { url in 36 | handleIncomingURL(url) 37 | } 38 | .alert("添加订阅源失败", isPresented: $showError) { 39 | Button("确定", role: .cancel) {} 40 | } message: { 41 | Text(errorMessage) 42 | } 43 | } 44 | } 45 | 46 | /// 处理传入的URL 47 | private func handleIncomingURL(_ url: URL) { 48 | logger.info("收到URL Scheme调用: \(url.absoluteString)") 49 | 50 | guard url.scheme == "rllm" else { 51 | logger.error("未知的URL scheme: \(url.scheme ?? "nil")") 52 | showError(message: "无效的URL格式") 53 | return 54 | } 55 | 56 | // 解析URL中的参数 57 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), 58 | let queryItems = components.queryItems else { 59 | logger.error("无法解析URL参数") 60 | showError(message: "无法解析URL参数") 61 | return 62 | } 63 | 64 | // 提取feed_url参数(必需)和feed_title参数(可选) 65 | guard let feedURL = queryItems.first(where: { $0.name == "feed_url" })?.value else { 66 | logger.error("缺少feed_url参数") 67 | showError(message: "缺少RSS源地址") 68 | return 69 | } 70 | 71 | let providedTitle = queryItems.first(where: { $0.name == "feed_title" })?.value 72 | logger.info("解析到RSS源: \(providedTitle ?? "未提供标题") - \(feedURL)") 73 | 74 | // 检查URL格式 75 | guard let _ = URL(string: feedURL) else { 76 | logger.error("无效的RSS源URL: \(feedURL)") 77 | showError(message: "无效的RSS源地址") 78 | return 79 | } 80 | 81 | // 检查是否已经订阅 82 | if articlesViewModel.feeds.contains(where: { $0.url == feedURL }) { 83 | logger.info("RSS源已存在: \(feedURL)") 84 | showError(message: "该RSS源已经添加过了") 85 | return 86 | } 87 | 88 | // 添加新的Feed 89 | Task { 90 | do { 91 | // 先验证Feed 92 | logger.info("正在验证RSS源: \(feedURL)") 93 | let validatedFeed = try await articlesViewModel.validateFeed(feedURL) 94 | 95 | // 使用验证后的Feed信息创建新Feed,优先使用提供的标题 96 | var feed = Feed( 97 | title: providedTitle ?? validatedFeed.title, // 优先使用提供的标题,否则使用验证获取的标题 98 | url: feedURL, 99 | description: validatedFeed.description, 100 | iconName: validatedFeed.iconName 101 | ) 102 | 103 | // 如果标题重复,生成新标题 104 | if articlesViewModel.feeds.contains(where: { $0.title == feed.title }) { 105 | var counter = 1 106 | var newTitle = feed.title 107 | while articlesViewModel.feeds.contains(where: { $0.title == newTitle }) { 108 | newTitle = "\(feed.title) (\(counter))" 109 | counter += 1 110 | } 111 | feed.title = newTitle 112 | logger.info("使用新标题: \(newTitle)") 113 | } 114 | 115 | // 添加验证通过的Feed 116 | try await articlesViewModel.addFeed(feed) 117 | logger.info("成功添加RSS源: \(feed.title)") 118 | HapticManager.shared.success() 119 | } catch { 120 | logger.error("添加RSS源失败: \(error.localizedDescription)") 121 | showError(message: "添加RSS源失败:\(error.localizedDescription)") 122 | } 123 | } 124 | } 125 | 126 | /// 显示错误信息 127 | private func showError(message: String) { 128 | errorMessage = message 129 | showError = true 130 | HapticManager.shared.error() 131 | } 132 | 133 | /// 请求网络权限 134 | private func requestNetworkPermission() { 135 | // 发起一个简单的网络请求来触发系统网络权限弹窗 136 | AF.request("https://www.apple.com").response { _ in 137 | // 不需要处理响应 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /RLLM/Models/ViewModels/QuotesViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import CoreData 4 | 5 | class QuotesViewModel: ObservableObject { 6 | static let shared = QuotesViewModel() 7 | 8 | @Published var quotes: [Quote] = [] 9 | private let coreDataManager = CoreDataManager.shared 10 | 11 | private init() { 12 | loadQuotes() 13 | } 14 | 15 | // MARK: - 数据加载 16 | 17 | private func loadQuotes() { 18 | quotes = coreDataManager.getAllQuotes() 19 | print("Loaded \(quotes.count) quotes from Core Data") 20 | } 21 | 22 | // MARK: - 多选相关 23 | 24 | /// 是否有选中的引用 25 | var hasSelectedQuotes: Bool { 26 | quotes.contains { $0.isSelected } 27 | } 28 | 29 | /// 是否所有引用都被选中 30 | var areAllQuotesSelected: Bool { 31 | !quotes.isEmpty && quotes.allSatisfy { $0.isSelected } 32 | } 33 | 34 | /// 切换引用的选中状态 35 | /// - Parameter quote: 要切换状态的引用 36 | func toggleQuoteSelection(_ quote: Quote) { 37 | if let index = quotes.firstIndex(where: { $0.id == quote.id }) { 38 | quotes[index].isSelected.toggle() 39 | } 40 | } 41 | 42 | /// 切换全选/取消全选 43 | /// - Parameter isSelected: 是否全选 44 | func toggleSelectAll(_ isSelected: Bool) { 45 | quotes.indices.forEach { index in 46 | quotes[index].isSelected = isSelected 47 | } 48 | } 49 | 50 | /// 重置所有选择状态 51 | func resetSelection() { 52 | quotes.indices.forEach { index in 53 | quotes[index].isSelected = false 54 | } 55 | } 56 | 57 | /// 删除选中的引用 58 | func deleteSelectedQuotes() { 59 | let selectedQuotes = quotes.enumerated().filter { $0.element.isSelected } 60 | let indexSet = IndexSet(selectedQuotes.map { $0.offset }) 61 | deleteQuotes(at: indexSet) 62 | } 63 | 64 | // MARK: - 引用操作 65 | 66 | func addQuote(_ content: String, from article: Article, isFullArticle: Bool = false) { 67 | let quote = Quote( 68 | content: content, 69 | articleTitle: article.title, 70 | articleURL: article.url, 71 | savedDate: Date(), 72 | isFullArticle: isFullArticle 73 | ) 74 | 75 | // 检查是否已存在完全相同的引用(包括内容) 76 | if !quotes.contains(where: { 77 | $0.articleURL == article.url && 78 | $0.isFullArticle == isFullArticle && 79 | $0.content == content 80 | }) { 81 | // 保存到Core Data 82 | _ = coreDataManager.createOrUpdateQuote(quote, articleId: article.id) 83 | 84 | // 更新本地数组 85 | quotes.insert(quote, at: 0) 86 | 87 | HapticManager.shared.lightImpact() 88 | // 显示成功提示 89 | ToastManager.shared.showSuccess( 90 | NSLocalizedString(isFullArticle ? "toast.quotes.article_saved.title" : "toast.quotes.text_saved.title", comment: "Quote saved title"), 91 | message: NSLocalizedString(isFullArticle ? "toast.quotes.article_saved.message" : "toast.quotes.text_saved.message", comment: "Quote saved message") 92 | ) 93 | print("Added new quote: \(isFullArticle ? "Full article" : "Text selection") from \(article.title)") 94 | } else { 95 | print("Quote already exists") 96 | } 97 | } 98 | 99 | func deleteQuotes(at offsets: IndexSet) { 100 | // 获取要删除的数量 101 | let count = offsets.count 102 | 103 | // 从Core Data中删除 104 | for index in offsets { 105 | let quote = quotes[index] 106 | coreDataManager.deleteQuote(id: quote.id) 107 | } 108 | 109 | // 更新本地数组 110 | quotes.remove(atOffsets: offsets) 111 | 112 | HapticManager.shared.lightImpact() 113 | 114 | // 显示删除提示 115 | ToastManager.shared.showWarning( 116 | NSLocalizedString("toast.quotes.deleted.title", comment: "Quotes deleted title"), 117 | message: String(format: NSLocalizedString("toast.quotes.deleted.message", comment: "Quotes deleted message"), count) 118 | ) 119 | print("Deleted quotes at offsets: \(offsets)") 120 | } 121 | 122 | func removeQuote(for articleURL: String, isFullArticle: Bool = false) { 123 | if let index = quotes.firstIndex(where: { 124 | $0.articleURL == articleURL && $0.isFullArticle == isFullArticle 125 | }) { 126 | // 从Core Data中删除 127 | let quote = quotes[index] 128 | coreDataManager.deleteQuote(id: quote.id) 129 | 130 | // 更新本地数组 131 | quotes.remove(at: index) 132 | 133 | HapticManager.shared.lightImpact() 134 | // 显示取消收藏提示 135 | ToastManager.shared.showWarning( 136 | NSLocalizedString(isFullArticle ? "toast.quotes.article_unsaved.title" : "toast.quotes.text_unsaved.title", comment: "Quote unsaved title"), 137 | message: NSLocalizedString(isFullArticle ? "toast.quotes.article_unsaved.message" : "toast.quotes.text_unsaved.message", comment: "Quote unsaved message") 138 | ) 139 | print("Removed quote for article: \(articleURL)") 140 | } 141 | } 142 | 143 | /// 刷新收藏列表 144 | func refresh() { 145 | loadQuotes() 146 | } 147 | } -------------------------------------------------------------------------------- /RLLM/Views/Articles/Components/FeedEditView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FeedEditView: View { 4 | let feed: Feed 5 | @Environment(\.dismiss) private var dismiss 6 | @EnvironmentObject var articlesViewModel: ArticlesViewModel 7 | @State private var title: String 8 | @State private var selectedIcon: String 9 | @State private var selectedColor: String 10 | 11 | private let icons = [ 12 | "newspaper.fill", // 新闻/媒体 13 | "doc.text.fill", // 文章/博客 14 | "terminal.fill", // 技术/开发 15 | "person.2.fill", // 社区/论坛 16 | "book.fill", // 杂志/期刊 17 | "globe.americas.fill", // 网站 18 | "quote.bubble.fill", // 评论/观点 19 | "chart.bar.fill" // 数据/分析 20 | ] 21 | 22 | private let colors: [(name: String, color: Color)] = [ 23 | ("AccentColor", .accentColor), 24 | ("red", .red), 25 | ("orange", .orange), 26 | ("yellow", .yellow), 27 | ("green", .green), 28 | ("mint", .mint), 29 | ("blue", .blue), 30 | ("indigo", .indigo), 31 | ("purple", .purple), 32 | ("pink", .pink) 33 | ] 34 | 35 | private var currentColor: Color { 36 | if selectedColor == "AccentColor" { 37 | return .accentColor 38 | } 39 | return colors.first { $0.name == selectedColor }?.color ?? .accentColor 40 | } 41 | 42 | init(feed: Feed) { 43 | self.feed = feed 44 | _title = State(initialValue: feed.title) 45 | _selectedIcon = State(initialValue: feed.iconName) 46 | _selectedColor = State(initialValue: feed.iconColor ?? "AccentColor") 47 | } 48 | 49 | var body: some View { 50 | Form { 51 | Section { 52 | TextField(NSLocalizedString("feed_edit.name", comment: "Feed name field"), text: $title) 53 | } header: { 54 | Text(NSLocalizedString("feed_edit.basic_info", comment: "Basic info section")) 55 | } 56 | 57 | Section { 58 | ScrollView(.horizontal, showsIndicators: false) { 59 | HStack(spacing: 16) { 60 | ForEach(icons, id: \.self) { icon in 61 | Image(systemName: icon) 62 | .font(.title2) 63 | .foregroundColor(currentColor) 64 | .frame(width: 24, height: 24) 65 | .padding(8) 66 | .background(currentColor.opacity(0.1)) 67 | .clipShape(Circle()) 68 | .overlay( 69 | Circle() 70 | .strokeBorder(currentColor, lineWidth: selectedIcon == icon ? 2 : 0) 71 | ) 72 | .onTapGesture { 73 | selectedIcon = icon 74 | HapticManager.shared.selection() 75 | } 76 | } 77 | } 78 | .padding(.vertical, 8) 79 | } 80 | 81 | ScrollView(.horizontal, showsIndicators: false) { 82 | HStack(spacing: 16) { 83 | ForEach(colors, id: \.name) { colorOption in 84 | Circle() 85 | .fill(colorOption.color) 86 | .frame(width: 24, height: 24) 87 | .padding(8) 88 | .background(colorOption.color.opacity(0.1)) 89 | .clipShape(Circle()) 90 | .overlay( 91 | Circle() 92 | .strokeBorder(colorOption.color, lineWidth: selectedColor == colorOption.name ? 2 : 0) 93 | ) 94 | .onTapGesture { 95 | selectedColor = colorOption.name 96 | HapticManager.shared.selection() 97 | } 98 | } 99 | } 100 | .padding(.vertical, 8) 101 | } 102 | } header: { 103 | Text(NSLocalizedString("feed_edit.icon_settings", comment: "Icon settings")) 104 | } 105 | } 106 | .navigationTitle(NSLocalizedString("feed_edit.title", comment: "Settings")) 107 | .navigationBarTitleDisplayMode(.inline) 108 | .toolbar { 109 | ToolbarItem(placement: .navigationBarLeading) { 110 | Button(NSLocalizedString("feed_edit.cancel", comment: "Cancel")) { 111 | dismiss() 112 | } 113 | } 114 | 115 | ToolbarItem(placement: .navigationBarTrailing) { 116 | Button(NSLocalizedString("feed_edit.done", comment: "Done")) { 117 | articlesViewModel.updateFeed( 118 | feed, 119 | title: title, 120 | icon: selectedIcon, 121 | color: selectedColor 122 | ) 123 | dismiss() 124 | } 125 | .disabled(title.isEmpty) 126 | } 127 | } 128 | } 129 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | RLLM Icon 4 | 5 | # RLLM 6 | 7 | 🌟 A LLM-Powered RSS Reader 8 | 9 | 🌐 Project Homepage 10 | 11 | [English](README.md) | [中文](README_CN.md) 12 | 13 | [![GitHub stars](https://img.shields.io/github/stars/DanielZhangyc/RLLM.svg?style=social)](https://github.com/DanielZhangyc/RLLM/stargazers) 14 | [![Build Status](https://github.com/DanielZhangyc/RLLM/actions/workflows/swift.yml/badge.svg)](https://github.com/DanielZhangyc/RLLM/actions/workflows/swift.yml) 15 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 16 | [![Swift](https://img.shields.io/badge/Swift-5.0-orange.svg)](https://swift.org) 17 | [![Platform](https://img.shields.io/badge/platform-iOS-lightgrey.svg)](https://www.apple.com/ios/) 18 | 19 |
20 | 21 | # 📖 RLLM - LLM-Powered RSS Reader 22 | 23 | RLLM is an innovative RSS reader powered by Large Language Models (LLM), providing intelligent content analysis and summarization capabilities. 24 | 25 | - [Features](#features) 26 | - [Screenshots](#screenshots) 27 | - [Installation](#installation) 28 | - [Development](#development) 29 | - [Contributing](#contributing) 30 | - [FAQ](#faq) 31 | - [License](#license) 32 | 33 | 34 | ## ✨ Features 35 | 36 | ### RSS Reading 37 | - ✅ Support for RSS 1.0, 2.0 and Atom feeds 38 | - ✅ Article/Quote Reading and Collection 39 | 40 | ### AI Features 41 | - ✅ AI Article Summary Generation 42 | - ✅ AI Article Insight Analysis 43 | - ✅ Daily Reading AI Summary 44 | - ✅ Integrated with Anthropic, Deepseek and OpenAI 45 | 46 | ### TODO 47 | - 📝 Enhanced Collection Management 48 | - 📝 Collection AI Summary 49 | - 📝 Recent Reading Analysis 50 | - 📝 Recent Reading Trends/Tags 51 | 52 | 53 | ## 📱 Screenshots 54 | 55 |
56 | Home 57 | AI Insights 58 | Quote Collection 59 | Daily Summary 60 |
61 | 62 | 63 | ## 📥 Installation 64 | 65 | ### Option 1: Build from Source Code 66 | 67 | See [Development](#development) section for detailed instructions on building from source code. 68 | 69 | ### Option 2: Install from IPA File 70 | 71 | 1. Download the latest unsigned IPA file from [GitHub Actions](https://github.com/DanielZhangyc/RLLM/actions) (Latest successful build) 72 | 2. Sign and install the IPA file using one of these methods: 73 | 74 | #### Using Signing Tools 75 | - [AltStore](https://altstore.io) - Popular sideloading tool with automatic resigning 76 | - [Sideloadly](https://sideloadly.io) - Cross-platform sideloading tool 77 | - [ESign](https://esign.yyyue.xyz) - On-device signing tool 78 | 79 | #### Using TrollStore (No Signing Required) 80 | - [TrollStore](https://github.com/opa334/TrollStore) - Permanent app installation for iOS 14.0-15.4.1, 15.5beta4, and 16.0-16.6.1 81 | 82 | #### Using Other Methods 83 | - [Scarlet](https://usescarlet.com) - On-device app installer 84 | - Your Apple Developer account and Xcode 85 | - Enterprise certificate (if you have access) 86 | 87 | Note: The IPA file is unsigned and requires signing before it can be installed on your device, except when using TrollStore on supported iOS versions. 88 | 89 | 90 | ## 👨‍💻 Development 91 | 92 | ### Prerequisites 93 | 94 | - Xcode 15.0+ 95 | - iOS 17.0+ 96 | - Swift 5.0+ 97 | 98 | ### Dependencies 99 | 100 | - [FeedKit](https://github.com/nmdias/FeedKit) - RSS and Atom feed parser 101 | - [Alamofire](https://github.com/Alamofire/Alamofire) - HTTP networking library 102 | 103 | ### Getting Started 104 | 105 | 1. Clone the repository 106 | ```bash 107 | git clone https://github.com/DanielZhangyc/RLLM.git 108 | cd RLLM 109 | ``` 110 | 111 | 2. Open the project in Xcode 112 | ```bash 113 | open RLLM.xcodeproj 114 | ``` 115 | 116 | 3. Build and run the project in Xcode 117 | 118 | 119 | ## 🤝 Contributing 120 | 121 | We welcome contributions! Here's how you can help: 122 | 123 | 1. Fork the repository 124 | 2. Create a new branch (`git checkout -b feature/amazing-feature`) 125 | 3. Make your changes 126 | 4. Commit your changes (`git commit -m 'Write something here'`) 127 | 5. Push to the branch (`git push origin feature/amazing-feature`) 128 | 6. Open a Pull Request 129 | 130 | Need help? Feel free to: 131 | - Open an issue 132 | - Start a discussion 133 | 134 | 135 | ## ❓ FAQ 136 | 137 | ### What's the origin of the name RLLM? 138 | RLLM is a combination of "RSS" and "LLM", representing our goal of enhancing the RSS reading experience with AI capabilities. 139 | 140 | ### Do I need to provide my own API keys? 141 | Yes, you need to provide your own API keys for the LLM services you want to use. These can be configured in the app settings. 142 | 143 | 144 | ## 📄 License 145 | 146 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 147 | 148 | [![Star History Chart](https://api.star-history.com/svg?repos=DanielZhangyc/RLLM&type=Date)](https://star-history.com/#DanielZhangyc/RLLM&Date) -------------------------------------------------------------------------------- /RLLM/Services/RSSService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Alamofire 3 | 4 | /// RSS服务,负责管理RSS订阅源的获取和缓存 5 | actor RSSService { 6 | // MARK: - Singleton 7 | 8 | /// 共享实例 9 | static let shared = RSSService() 10 | 11 | // MARK: - Properties 12 | 13 | /// 文章缓存,键为Feed URL,值为最后更新时间和文章列表 14 | private var cache: [String: (lastUpdate: Date, articles: [Article])] = [:] 15 | 16 | /// 最小更新间隔(1小时) 17 | private let minimumUpdateInterval: TimeInterval = 3600 18 | 19 | // MARK: - Initialization 20 | 21 | private init() { } 22 | 23 | // MARK: - Public Methods 24 | 25 | /// 获取指定Feed的文章列表 26 | /// - Parameters: 27 | /// - feed: 要获取的Feed 28 | /// - existingArticles: 已存在的文章列表,用于去重 29 | /// - forceRefresh: 是否强制刷新,忽略缓存 30 | /// - Returns: 更新后的文章列表 31 | /// - Throws: RSSError 32 | func fetchArticles(from feed: Feed, existingArticles: [Article] = [], forceRefresh: Bool = false) async throws -> [Article] { 33 | print("\n--- Fetching articles for \(feed.title) ---") 34 | print("Feed URL: \(feed.url)") 35 | 36 | // 检查是否需要更新 37 | if !forceRefresh, 38 | let cacheEntry = cache[feed.url], 39 | Date().timeIntervalSince(cacheEntry.lastUpdate) < minimumUpdateInterval { 40 | print("Using cached data, last update: \(cacheEntry.lastUpdate)") 41 | return cacheEntry.articles 42 | } 43 | 44 | print("Fetching fresh data from network") 45 | guard let feedURL = URL(string: feed.url) else { 46 | throw RSSError.invalidURL 47 | } 48 | 49 | let data = try await withCheckedThrowingContinuation { continuation in 50 | AF.request(feedURL) 51 | .validate() 52 | .responseData { response in 53 | switch response.result { 54 | case .success(let data): 55 | continuation.resume(returning: data) 56 | case .failure(let error): 57 | continuation.resume(throwing: RSSError.fetchError(error)) 58 | } 59 | } 60 | } 61 | 62 | let parser = RSSParser() 63 | let (_, parsedArticles) = try parser.parse(data: data) 64 | 65 | // 使用URL作为唯一标识符进行去重 66 | let existingUrls = Set(existingArticles.map { $0.url }) 67 | let newArticles = parsedArticles 68 | .filter { !existingUrls.contains($0.url) } 69 | .map { article in 70 | Article( 71 | id: article.id, 72 | title: article.title, 73 | content: article.content, 74 | url: article.url, 75 | publishDate: article.publishDate, 76 | feedTitle: feed.title, 77 | author: article.author, 78 | isRead: article.isRead, 79 | summary: article.summary 80 | ) 81 | } 82 | 83 | print("Found \(newArticles.count) new articles") 84 | 85 | // 合并新旧文章并按发布日期排序 86 | var allArticles = existingArticles 87 | allArticles.append(contentsOf: newArticles) 88 | allArticles.sort { $0.publishDate > $1.publishDate } 89 | 90 | // 更新缓存 91 | cache[feed.url] = (lastUpdate: Date(), articles: allArticles) 92 | print("Updated cache with \(allArticles.count) total articles") 93 | 94 | return allArticles 95 | } 96 | 97 | // MARK: - Private Methods 98 | 99 | /// 解码HTML实体 100 | /// - Parameter text: 包含HTML实体的文本 101 | /// - Returns: 解码后的文本 102 | private func decodeHTMLEntities(_ text: String) -> String { 103 | guard let data = text.data(using: .utf8) else { return text } 104 | let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ 105 | .documentType: NSAttributedString.DocumentType.html, 106 | .characterEncoding: String.Encoding.utf8.rawValue 107 | ] 108 | 109 | if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) { 110 | return attributedString.string 111 | } 112 | return text 113 | } 114 | 115 | /// 验证Feed的有效性 116 | /// - Parameter url: Feed的URL字符串 117 | /// - Returns: 验证通过的Feed对象 118 | /// - Throws: RSSError 119 | func validateFeed(_ url: String) async throws -> Feed { 120 | print("Validating feed URL: \(url)") 121 | guard let feedURL = URL(string: url) else { 122 | throw RSSError.invalidURL 123 | } 124 | 125 | let data = try await withCheckedThrowingContinuation { continuation in 126 | AF.request(feedURL) 127 | .validate() 128 | .responseData { response in 129 | switch response.result { 130 | case .success(let data): 131 | continuation.resume(returning: data) 132 | case .failure(let error): 133 | continuation.resume(throwing: RSSError.fetchError(error)) 134 | } 135 | } 136 | } 137 | 138 | let parser = RSSParser() 139 | let (feed, _) = try parser.parse(data: data) 140 | 141 | return Feed( 142 | id: UUID(), 143 | title: feed.title, 144 | url: url, 145 | description: feed.description 146 | ) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /RLLM/Models/LLMPrompts.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// LLM提示词管理器 4 | /// 集中管理所有用于LLM的提示词模板 5 | enum LLMPrompts { 6 | 7 | // MARK: - 文章分析相关 8 | 9 | /// 生成文章概要的提示词 10 | static let summary = """ 11 | 你是一个专业的文章概括助手。请对以下文章进行简洁的总结,遵循以下要求: 12 | 13 | 1. 总结要求: 14 | - 不要使用markdown格式,使用纯文本 15 | - 使用自然流畅的语言 16 | - 突出文章的核心内容和主要观点 17 | - 保持客观中立的语气 18 | - 使用准确但通俗易懂的表述 19 | - 避免过于技术性的术语 20 | - 总结长度控制在200字以内 21 | 22 | 2. 针对不同类型的处理重点: 23 | - 新闻报道:关注时间、地点、人物、事件发展 24 | - 技术文章:关注技术原理、应用场景、优缺点 25 | - 观点评论:关注论点论据、观点立场、推理过程 26 | - 教程指南:关注步骤方法、注意事项、应用场景 27 | 文章内容: 28 | {article_content} 29 | """ 30 | 31 | /// 生成文章深度洞察的提示词 32 | static let insight = """ 33 | 你是一位专业的内容分析师。请严格按照以下模板格式分析文章。注意:必须完全按照示例格式输出,不得改变任何标记或格式: 34 | 35 | [主题标签] 36 | 标签1,标签2,标签3 37 | (要求: 38 | 1. 必须使用英文逗号分隔 39 | 2. 提供3-5个专业且具体的标签 40 | 3. 标签按重要性排序 41 | 4. 标签应包含:领域分类、技术主题、应用场景等维度 42 | 5. 避免过于宽泛的标签,如"技术"、"科技" 43 | 6. 使用准确的专业术语 44 | 7.标签语言跟随整体语言) 45 | 46 | [核心摘要] 47 | 这里是核心摘要内容。要求: 48 | 1. 摘要应该是一段完整的文字,不要使用要点形式 49 | 2. 控制在100-150字之间 50 | 3. 包含文章的核心价值和创新点 51 | 4. 突出文章的实际应用意义 52 | 5. 使用专业且准确的描述 53 | 54 | [关键观点] 55 | - 这里是第一个关键观点 56 | - 这里是第二个关键观点 57 | - 这里是第三个关键观点 58 | (要求: 59 | 1. 每个观点控制在30字以内 60 | 2. 按重要性排序 61 | 3. 确保观点之间相互独立 62 | 4. 使用清晰的因果关系或逻辑关系 63 | 5. 突出实用性和可操作性) 64 | 65 | [情感倾向] 66 | 这里是情感倾向描述 67 | (要求: 68 | 1. 分析文章的立场和态度 69 | 2. 评估内容的客观性程度 70 | 3. 指出潜在的偏见或倾向 71 | 4. 说明文章的目标受众 72 | 5. 评估文章的说服力和可信度) 73 | 74 | [背景补充] 75 | 这里是背景补充内容 76 | (要求: 77 | 1. 提供相关领域的发展历史 78 | 2. 说明当前行业现状 79 | 3. 指出未来发展趋势 80 | 4. 补充相关知识链接 81 | 5. 建议延伸阅读方向) 82 | 83 | 严格遵守以下规则: 84 | 1. 必须使用以上完全相同的5个标记 85 | 2. 每个标记必须单独占一行 86 | 3. 内容必须紧跟在标记后面 87 | 4. 关键观点必须使用'-'开头 88 | 5. 主题标签必须用英文逗号分隔,不要使用中文逗号 89 | 7. 不允许添加任何其他标记或格式 90 | 8. 不允许使用"要点:"、"结论:"等其他标记 91 | 9. 标记必须完全一致,包括中括号 92 | 10. 除[背景补充]外,其他部分均为必需 93 | 11. 违反任何格式要求都是不允许的 94 | 95 | 分析以下文章: 96 | {article_content} 97 | """ 98 | 99 | // MARK: - 收藏内容分析 100 | 101 | /// 生成收藏内容总结的提示词 102 | static let collectionSummary = """ 103 | 你是一位资深的内容策划和知识管理专家。请分析以下收藏的内容片段,并提供深度洞察: 104 | 105 | 分析维度: 106 | 1. 知识主题: 107 | - 识别核心主题和概念 108 | - 发现主题间的关联性 109 | - 构建知识体系框架 110 | 111 | 2. 内容价值: 112 | - 提炼实用的见解 113 | - 总结可操作的方法 114 | - 归纳普适性原则 115 | 116 | 3. 应用指导: 117 | - 提供实践建议 118 | - 指出应用场景 119 | - 预警潜在问题 120 | 121 | 4. 延伸建议: 122 | - 推荐深入学习方向 123 | - 建议补充知识领域 124 | - 提供参考资源链接 125 | 126 | 输出要求: 127 | 1. 保持专业性和逻辑性 128 | 2. 突出实用价值和可操作性 129 | 3. 注重知识的系统性和完整性 130 | 4. 提供清晰的行动建议 131 | 132 | 收藏内容: 133 | {collection_content} 134 | """ 135 | 136 | // MARK: - 阅读偏好分析 137 | 138 | /// 生成阅读偏好分析的提示词 139 | static let readingPreference = """ 140 | 你是一位专业的用户行为分析师和学习顾问。请分析以下阅读历史数据,并提供个性化建议: 141 | 142 | 分析维度: 143 | 1. 兴趣主题: 144 | - 识别高频主题和概念 145 | - 发现主题演变趋势 146 | - 评估知识覆盖广度 147 | 148 | 2. 阅读模式: 149 | - 分析阅读时间分布 150 | - 识别内容难度偏好 151 | - 评估阅读深度 152 | 153 | 3. 知识结构: 154 | - 识别知识体系优势 155 | - 发现知识盲点 156 | - 评估知识连贯性 157 | 158 | 4. 学习建议: 159 | - 推荐优先学习方向 160 | - 建议合适的学习资源 161 | - 提供学习路径规划 162 | 163 | 输出要求: 164 | 1. 提供数据支持的分析结论 165 | 2. 给出可执行的具体建议 166 | 3. 注重建议的可行性 167 | 4. 考虑用户当前水平和目标 168 | 169 | 阅读历史: 170 | {reading_history} 171 | """ 172 | 173 | /// 生成今日阅读总结的提示词 174 | static let dailySummary = """ 175 | 你是一位专业的阅读顾问。请根据以下今日阅读记录生成一份简洁的总结报告: 176 | 177 | 分析要求: 178 | 1. 内容分析: 179 | - 识别主要阅读主题 180 | - 总结核心知识点 181 | - 发现知识关联性 182 | 183 | 2. 知识价值: 184 | - 提炼关键见解 185 | - 总结实用方法 186 | - 归纳普适原则 187 | 188 | 3. 输出格式: 189 | [核心摘要] 190 | 今日阅读内容概述(100字以内,重点关注知识收获) 191 | 192 | [关键观点] 193 | • 观点1(20字以内) 194 | • 观点2(20字以内) 195 | • 观点3(20字以内) 196 | 197 | [学习建议] 198 | 针对性的改进建议(50字以内) 199 | 200 | 注意事项: 201 | 1. 必须严格按照以上格式输出 202 | 2. 必须包含所有三个部分 203 | 3. 标记必须完全一致,包括中括号 204 | 4. 不允许添加其他标记或格式 205 | 5. 确保内容清晰易读 206 | 207 | 阅读记录: 208 | {reading_records} 209 | """ 210 | 211 | /// 分析热门话题的提示词 212 | static let topicAnalysis = """ 213 | 你是一位专业的内容分析师。请分析以下阅读记录中的热门话题: 214 | 215 | 分析要求: 216 | 1. 话题提取: 217 | - 识别文章中的主要话题 218 | - 合并相似或相关话题 219 | - 确保话题的专业性和具体性 220 | - 使用准确专业术语 221 | 222 | 2. 话题分类: 223 | - 按领域分类(技术、商业、科学等) 224 | - 识别话题间的关联性 225 | - 评估话题的时效性 226 | - 保持表述的一致性 227 | 228 | 3. 输出格式: 229 | [热门话题] 230 | 话题1,话题2,话题3,话题4,话题5 231 | (使用英文逗号分隔,按热度排序,限制5-8个话题) 232 | 233 | [话题分布] 234 | 话题1: N篇 235 | 话题2: N篇 236 | 话题3: N篇 237 | ... 238 | (按文章数量降序排列) 239 | 240 | 注意事项: 241 | 1. 确保话题的专业性和具体性 242 | 2. 保持话题表述的一致性和规范性 243 | 3. 话题语言跟随系统语言 244 | 245 | 阅读记录: 246 | {reading_records} 247 | """ 248 | 249 | // MARK: - 工具方法 250 | 251 | /// 替换提示词中的占位符 252 | /// - Parameters: 253 | /// - template: 提示词模板 254 | /// - replacements: 要替换的键值对 255 | /// - Returns: 替换后的提示词 256 | static func format(_ template: String, with replacements: [String: String]) -> String { 257 | var result = template 258 | for (key, value) in replacements { 259 | result = result.replacingOccurrences(of: "{\(key)}", with: value) 260 | } 261 | return result 262 | } 263 | } -------------------------------------------------------------------------------- /RLLM/Models/LLMTypes.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// LLM服务提供商枚举 4 | /// 定义了支持的各种LLM服务提供商 5 | enum LLMProvider: String, Codable, CaseIterable { 6 | // MARK: - Cases 7 | 8 | /// OpenAI API服务 9 | case openAI = "OpenAI" 10 | 11 | /// Anthropic API服务 12 | case anthropic = "Anthropic" 13 | 14 | /// 自定义API服务 15 | case custom = "Custom" 16 | 17 | /// Deepseek API服务 18 | case deepseek = "Deepseek" 19 | 20 | // MARK: - Static Properties 21 | 22 | /// 返回排序后的提供者列表,自定义始终在底部,其他选项按字母顺序排序 23 | static var sortedCases: [LLMProvider] { 24 | let allCases = Self.allCases.filter { $0 != .custom } 25 | let sorted = allCases.sorted { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } 26 | return sorted + [.custom] 27 | } 28 | 29 | // MARK: - Properties 30 | 31 | /// 获取提供商的默认API基础URL 32 | var defaultBaseURL: String { 33 | switch self { 34 | case .openAI: 35 | return "https://api.openai.com/v1" 36 | case .anthropic: 37 | return "https://api.anthropic.com" 38 | case .custom: 39 | return "" 40 | case .deepseek: 41 | return "https://api.deepseek.com" 42 | } 43 | } 44 | 45 | /// 获取提供商支持的默认模型列表 46 | var defaultModels: [String] { 47 | switch self { 48 | case .openAI: 49 | return ["gpt-4", "gpt-3.5-turbo", "gpt-4o"] 50 | case .anthropic: 51 | return ["claude-3-opus", "claude-3-sonnet", "claude-3-haiku"] 52 | case .custom: 53 | return [] 54 | case .deepseek: 55 | return ["deepseek-chat"] 56 | } 57 | } 58 | 59 | /// 获取提供商的显示名称 60 | var displayName: String { 61 | switch self { 62 | case .custom: 63 | return NSLocalizedString("provider.custom", comment: "Custom provider") 64 | default: 65 | return rawValue 66 | } 67 | } 68 | } 69 | 70 | /// LLM配置结构体 71 | /// 定义了与LLM服务交互所需的配置参数 72 | struct LLMConfig: Codable, Equatable { 73 | // MARK: - Properties 74 | 75 | /// LLM服务提供商 76 | var provider: LLMProvider 77 | 78 | /// API基础URL 79 | var baseURL: String 80 | 81 | /// API密钥 82 | var apiKey: String 83 | 84 | /// 使用的模型名称 85 | var model: String 86 | 87 | /// 温度参数,控制输出的随机性 88 | var temperature: Double 89 | 90 | /// 最大输出token数 91 | var maxTokens: Int 92 | 93 | // MARK: - Static Properties 94 | 95 | /// 默认配置 96 | static let defaultConfig = LLMConfig( 97 | provider: .openAI, 98 | baseURL: "https://api.openai.com/v1", 99 | apiKey: "", 100 | model: "gpt-4o", 101 | temperature: 0.7, 102 | maxTokens: 1000 103 | ) 104 | 105 | // MARK: - Equatable 106 | 107 | static func == (lhs: LLMConfig, rhs: LLMConfig) -> Bool { 108 | return lhs.provider == rhs.provider && 109 | lhs.baseURL == rhs.baseURL && 110 | lhs.apiKey == rhs.apiKey && 111 | lhs.model == rhs.model && 112 | lhs.temperature == rhs.temperature && 113 | lhs.maxTokens == rhs.maxTokens 114 | } 115 | } 116 | 117 | /// LLM响应结构体 118 | /// 定义了LLM服务返回的响应格式 119 | struct LLMResponse: Decodable { 120 | // MARK: - Properties 121 | 122 | /// 响应内容 123 | let content: String 124 | 125 | /// 错误信息,如果有的话 126 | let error: String? 127 | } 128 | 129 | /// 模型信息结构体 130 | /// 定义了LLM模型的详细信息 131 | struct Model: Identifiable, Codable, Hashable { 132 | // MARK: - Properties 133 | 134 | /// 模型的唯一标识符 135 | let id: String 136 | 137 | /// 模型的显示名称 138 | let name: String 139 | 140 | /// 模型的描述信息 141 | let description: String? 142 | 143 | /// 模型的上下文长度限制 144 | let contextLength: Int? 145 | 146 | /// 模型的提供商信息 147 | let provider: String? 148 | 149 | // MARK: - Computed Properties 150 | 151 | /// 检查是否是思维链模型 152 | /// 通过模型名称中的关键词判断 153 | var isThinkingModel: Bool { 154 | let modelName = name.lowercased() 155 | let components = modelName.components(separatedBy: CharacterSet.alphanumerics.inverted) 156 | 157 | // 检查常见的思维链关键词 158 | if modelName.contains("thinking") || 159 | modelName.contains("thought") || 160 | modelName.contains("cot") { 161 | return true 162 | } 163 | 164 | // 检查 "o1" 是否作为独立标记出现 165 | return components.contains("o1") 166 | } 167 | 168 | // MARK: - Coding Keys 169 | 170 | enum CodingKeys: String, CodingKey { 171 | case id 172 | case description 173 | case contextLength = "context_length" 174 | case provider 175 | } 176 | 177 | // MARK: - Initialization 178 | 179 | /// 从解码器创建Model实例 180 | init(from decoder: Decoder) throws { 181 | let container = try decoder.container(keyedBy: CodingKeys.self) 182 | id = try container.decode(String.self, forKey: .id) 183 | name = id // 使用id作为name 184 | description = try container.decodeIfPresent(String.self, forKey: .description) 185 | contextLength = try container.decodeIfPresent(Int.self, forKey: .contextLength) 186 | provider = try container.decodeIfPresent(String.self, forKey: .provider) 187 | } 188 | 189 | /// 创建一个新的Model实例 190 | /// - Parameters: 191 | /// - id: 模型ID 192 | /// - name: 模型名称,如果为nil则使用id 193 | /// - description: 模型描述 194 | /// - contextLength: 上下文长度限制 195 | /// - provider: 提供商信息 196 | init(id: String, name: String? = nil, description: String? = nil, contextLength: Int? = nil, provider: String? = nil) { 197 | self.id = id 198 | self.name = name ?? id // 如果没有提供name,使用id 199 | self.description = description 200 | self.contextLength = contextLength 201 | self.provider = provider 202 | } 203 | 204 | // MARK: - Hashable 205 | 206 | /// 实现Hashable协议的hash方法 207 | func hash(into hasher: inout Hasher) { 208 | hasher.combine(id) 209 | } 210 | 211 | /// 实现Equatable协议的相等性判断 212 | static func == (lhs: Model, rhs: Model) -> Bool { 213 | lhs.id == rhs.id 214 | } 215 | } -------------------------------------------------------------------------------- /RLLM/Views/Settings/AICacheManagementView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AICacheManagementView: View { 4 | @State private var showingClearSummaryCacheAlert = false 5 | @State private var showingClearInsightCacheAlert = false 6 | @State private var showingClearDailySummaryCacheAlert = false 7 | @State private var showingClearAllAlert = false 8 | 9 | var body: some View { 10 | List { 11 | Section { 12 | Button(role: .destructive) { 13 | showingClearAllAlert = true 14 | } label: { 15 | HStack { 16 | Text(NSLocalizedString("ai_cache.clear_all", comment: "Clear all AI cache")) 17 | Spacer() 18 | let totalSize = summaryStats.totalSize + insightStats.totalSize + dailySummaryStats.totalSize 19 | Text(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)) 20 | .foregroundColor(.secondary) 21 | } 22 | } 23 | } 24 | 25 | Section { 26 | Button(role: .destructive) { 27 | showingClearSummaryCacheAlert = true 28 | } label: { 29 | HStack { 30 | Text(NSLocalizedString("ai_cache.summary_cache", comment: "AI summary cache")) 31 | Spacer() 32 | Text(String(format: NSLocalizedString("ai_cache.entry_count_size", comment: "Entry count and size"), summaryStats.entryCount, ByteCountFormatter.string(fromByteCount: summaryStats.totalSize, countStyle: .file))) 33 | .foregroundColor(.secondary) 34 | } 35 | } 36 | 37 | Button(role: .destructive) { 38 | showingClearInsightCacheAlert = true 39 | } label: { 40 | HStack { 41 | Text(NSLocalizedString("ai_cache.insight_cache", comment: "AI insight cache")) 42 | Spacer() 43 | Text(String(format: NSLocalizedString("ai_cache.entry_count_size", comment: "Entry count and size"), insightStats.entryCount, ByteCountFormatter.string(fromByteCount: insightStats.totalSize, countStyle: .file))) 44 | .foregroundColor(.secondary) 45 | } 46 | } 47 | 48 | Button(role: .destructive) { 49 | showingClearDailySummaryCacheAlert = true 50 | } label: { 51 | HStack { 52 | Text(NSLocalizedString("ai_cache.daily_summary_cache", comment: "Daily summary cache")) 53 | Spacer() 54 | Text(String(format: NSLocalizedString("ai_cache.entry_count_size", comment: "Entry count and size"), dailySummaryStats.entryCount, ByteCountFormatter.string(fromByteCount: dailySummaryStats.totalSize, countStyle: .file))) 55 | .foregroundColor(.secondary) 56 | } 57 | } 58 | } footer: { 59 | if summaryStats.expiredCount > 0 || insightStats.expiredCount > 0 { 60 | Text(String(format: NSLocalizedString("ai_cache.expired_entries", comment: "Expired entries"), summaryStats.expiredCount + insightStats.expiredCount)) 61 | } 62 | } 63 | } 64 | .navigationTitle(NSLocalizedString("ai_cache.title", comment: "AI cache management")) 65 | .alert(NSLocalizedString("ai_cache.clear_all_title", comment: "Clear all AI cache confirmation"), isPresented: $showingClearAllAlert) { 66 | Button(NSLocalizedString("ai_cache.cancel", comment: "Cancel button"), role: .cancel) { } 67 | Button(NSLocalizedString("ai_cache.clear", comment: "Clear button"), role: .destructive) { 68 | SummaryCache.shared.clear() 69 | InsightCache.shared.clear() 70 | DailySummaryCache.shared.clear() 71 | } 72 | } message: { 73 | Text(NSLocalizedString("ai_cache.clear_all_message", comment: "Clear all AI cache message")) 74 | } 75 | .alert(NSLocalizedString("ai_cache.clear_summary_title", comment: "Clear summary cache confirmation"), isPresented: $showingClearSummaryCacheAlert) { 76 | Button(NSLocalizedString("ai_cache.cancel", comment: "Cancel button"), role: .cancel) { } 77 | Button(NSLocalizedString("ai_cache.clear", comment: "Clear button"), role: .destructive) { 78 | SummaryCache.shared.clear() 79 | } 80 | } message: { 81 | Text(NSLocalizedString("ai_cache.clear_summary_message", comment: "Clear summary cache message")) 82 | } 83 | .alert(NSLocalizedString("ai_cache.clear_insight_title", comment: "Clear insight cache confirmation"), isPresented: $showingClearInsightCacheAlert) { 84 | Button(NSLocalizedString("ai_cache.cancel", comment: "Cancel button"), role: .cancel) { } 85 | Button(NSLocalizedString("ai_cache.clear", comment: "Clear button"), role: .destructive) { 86 | InsightCache.shared.clear() 87 | } 88 | } message: { 89 | Text(NSLocalizedString("ai_cache.clear_insight_message", comment: "Clear insight cache message")) 90 | } 91 | .alert(NSLocalizedString("ai_cache.clear_daily_summary_title", comment: "Clear daily summary cache confirmation"), isPresented: $showingClearDailySummaryCacheAlert) { 92 | Button(NSLocalizedString("ai_cache.cancel", comment: "Cancel button"), role: .cancel) { } 93 | Button(NSLocalizedString("ai_cache.clear", comment: "Clear button"), role: .destructive) { 94 | DailySummaryCache.shared.clear() 95 | } 96 | } message: { 97 | Text(NSLocalizedString("ai_cache.clear_daily_summary_message", comment: "Clear daily summary cache message")) 98 | } 99 | } 100 | 101 | private var summaryStats: CacheStats { 102 | SummaryCache.shared.getStats() 103 | } 104 | 105 | private var insightStats: CacheStats { 106 | InsightCache.shared.getStats() 107 | } 108 | 109 | private var dailySummaryStats: CacheStats { 110 | DailySummaryCache.shared.getStats() 111 | } 112 | } -------------------------------------------------------------------------------- /RLLM/Models/RLLM.xcdatamodeld/RLLM.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /RLLM/Models/ReadingHistory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | /// 阅读记录模型 5 | struct ReadingRecord: Codable, Identifiable { 6 | let id: UUID 7 | let articleId: String 8 | let articleTitle: String 9 | let articleURL: String 10 | let startTime: Date 11 | let duration: TimeInterval // 阅读时长(秒) 12 | 13 | init( 14 | id: UUID = UUID(), 15 | articleId: String, 16 | articleTitle: String, 17 | articleURL: String, 18 | startTime: Date = Date(), 19 | duration: TimeInterval 20 | ) { 21 | self.id = id 22 | self.articleId = articleId 23 | self.articleTitle = articleTitle 24 | self.articleURL = articleURL 25 | self.startTime = startTime 26 | self.duration = duration 27 | } 28 | } 29 | 30 | /// 阅读统计模型 31 | struct ReadingStats: Codable { 32 | var totalReadingTime: TimeInterval // 总阅读时长 33 | var articleCount: Int // 阅读文章数 34 | var date: Date // 统计日期 35 | var actualReadingDays: Int = 1 // 实际阅读天数,默认为1 36 | 37 | init( 38 | totalReadingTime: TimeInterval = 0, 39 | articleCount: Int = 0, 40 | date: Date = Date(), 41 | actualReadingDays: Int = 1 42 | ) { 43 | self.totalReadingTime = totalReadingTime 44 | self.articleCount = articleCount 45 | self.date = date 46 | self.actualReadingDays = actualReadingDays 47 | } 48 | 49 | /// 计算平均每日阅读时长(仅在查看周/月统计时有意义,且只计入有阅读记录的天数) 50 | var averageDailyTime: TimeInterval { 51 | // 如果总阅读时长为0,直接返回0 52 | guard totalReadingTime > 0 else { return 0 } 53 | 54 | // 如果是当天的统计,直接返回总时长 55 | guard let daysCount = Calendar.current.dateComponents([.day], from: date, to: Date()).day, 56 | daysCount > 0 else { 57 | return totalReadingTime 58 | } 59 | 60 | // 使用实际阅读天数计算平均值 61 | return totalReadingTime / Double(max(1, actualReadingDays)) 62 | } 63 | } 64 | 65 | /// 阅读历史管理器 66 | class ReadingHistoryManager: ObservableObject { 67 | static let shared = ReadingHistoryManager() 68 | 69 | /// 最小记录阅读时长(秒) 70 | static let minimumReadingDuration: TimeInterval = 30 71 | 72 | @Published var dailyStats: [Date: ReadingStats] = [:] 73 | @Published var readingRecords: [ReadingRecord] = [] 74 | 75 | private let coreDataManager = CoreDataManager.shared 76 | private let calendar = Calendar.current 77 | 78 | private init() { 79 | loadData() 80 | } 81 | 82 | private func loadData() { 83 | // 加载今天的统计数据 84 | let today = calendar.startOfDay(for: Date()) 85 | if let stats = coreDataManager.getReadingStats(for: today) { 86 | dailyStats[today] = stats 87 | } 88 | 89 | // 加载最近30天的阅读记录 90 | let thirtyDaysAgo = calendar.date(byAdding: .day, value: -30, to: today)! 91 | readingRecords = coreDataManager.getReadingRecords(from: thirtyDaysAgo, to: Date()) 92 | } 93 | 94 | /// 添加或更新阅读记录 95 | func addRecord(_ record: ReadingRecord) { 96 | guard let articleId = UUID(uuidString: record.articleId) else { return } 97 | 98 | // 查找是否存在同一文章的记录 99 | if let existingIndex = readingRecords.firstIndex(where: { $0.articleId == record.articleId }) { 100 | let existingRecord = readingRecords[existingIndex] 101 | // 创建新记录,累加时长,保留最早的开始时间 102 | let updatedRecord = ReadingRecord( 103 | id: UUID(), // 生成新的ID 104 | articleId: record.articleId, 105 | articleTitle: record.articleTitle, 106 | articleURL: record.articleURL, 107 | startTime: min(existingRecord.startTime, record.startTime), // 保留最早的开始时间 108 | duration: existingRecord.duration + record.duration // 累加时长 109 | ) 110 | // 更新Core Data中的记录 111 | _ = coreDataManager.createOrUpdateReadingRecord(updatedRecord, articleId: articleId) 112 | // 更新内存中的记录 113 | readingRecords[existingIndex] = updatedRecord 114 | } else { 115 | // 如果是新文章,直接添加记录 116 | _ = coreDataManager.createOrUpdateReadingRecord(record, articleId: articleId) 117 | readingRecords.insert(record, at: 0) 118 | } 119 | 120 | // 更新统计数据 121 | updateDailyStats(with: record) 122 | } 123 | 124 | /// 更新每日统计数据 125 | private func updateDailyStats(with record: ReadingRecord) { 126 | let date = calendar.startOfDay(for: record.startTime) 127 | var stats = dailyStats[date] ?? ReadingStats(date: date) 128 | 129 | // 如果是更新现有记录,不增加文章计数 130 | let isExistingArticle = readingRecords.contains { 131 | $0.articleId == record.articleId && 132 | calendar.isDate($0.startTime, inSameDayAs: record.startTime) 133 | } 134 | 135 | if !isExistingArticle { 136 | stats.articleCount += 1 137 | } 138 | 139 | stats.totalReadingTime += record.duration 140 | 141 | // 更新Core Data中的统计数据 142 | _ = coreDataManager.createOrUpdateReadingStats(stats) 143 | 144 | // 更新内存中的统计数据 145 | dailyStats[date] = stats 146 | } 147 | 148 | /// 获取指定日期范围内有阅读记录的天数 149 | private func getActualReadingDays(from startDate: Date, to endDate: Date) -> Int { 150 | let daysWithReading = Set(readingRecords 151 | .filter { $0.startTime >= startDate && $0.startTime <= endDate } 152 | .map { calendar.startOfDay(for: $0.startTime) } 153 | ) 154 | return daysWithReading.count 155 | } 156 | 157 | /// 获取指定日期范围的阅读统计 158 | func getStats(from startDate: Date, to endDate: Date) -> ReadingStats { 159 | coreDataManager.getReadingStats(from: startDate, to: endDate) 160 | } 161 | 162 | /// 获取指定日期的阅读统计 163 | func getStats(for date: Date) -> ReadingStats { 164 | let startOfDay = calendar.startOfDay(for: date) 165 | if let stats = coreDataManager.getReadingStats(for: startOfDay) { 166 | return stats 167 | } 168 | return ReadingStats(date: startOfDay) 169 | } 170 | 171 | /// 获取指定日期范围的阅读记录 172 | func getRecords(from startDate: Date, to endDate: Date) -> [ReadingRecord] { 173 | coreDataManager.getReadingRecords(from: startDate, to: endDate) 174 | } 175 | 176 | /// 获取今日阅读时长(分钟) 177 | var todayReadingMinutes: Double { 178 | let today = calendar.startOfDay(for: Date()) 179 | return dailyStats[today]?.totalReadingTime ?? 0 / 60.0 180 | } 181 | 182 | /// 获取本周阅读时长(分钟) 183 | var weeklyReadingMinutes: Double { 184 | let calendar = Calendar.current 185 | let today = Date() 186 | guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today)) else { 187 | return 0 188 | } 189 | 190 | let stats = getStats(from: weekStart, to: today) 191 | return stats.totalReadingTime / 60.0 192 | } 193 | 194 | /// 清除超过30天的历史记录 195 | func cleanOldRecords() { 196 | let thirtyDaysAgo = calendar.date(byAdding: .day, value: -30, to: Date())! 197 | coreDataManager.cleanupReadingHistory(olderThan: thirtyDaysAgo) 198 | loadData() // 重新加载数据 199 | } 200 | 201 | /// 清除所有阅读记录 202 | func clearAllRecords() { 203 | let longTimeAgo = Date.distantPast 204 | coreDataManager.cleanupReadingHistory(olderThan: longTimeAgo) 205 | dailyStats.removeAll() 206 | readingRecords.removeAll() 207 | } 208 | } -------------------------------------------------------------------------------- /RLLM/Views/Articles/Components/FeedCardView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Date { 4 | func timeAgoDisplay() -> String { 5 | let calendar = Calendar.current 6 | let now = Date() 7 | let components = calendar.dateComponents([.minute, .hour, .day], from: self, to: now) 8 | 9 | if let day = components.day, day > 0 { 10 | if day == 1 { 11 | return NSLocalizedString("time.one_day_ago", comment: "One day ago") 12 | } 13 | if day < 7 { 14 | return String(format: NSLocalizedString("time.days_ago", comment: "Days ago"), day) 15 | } 16 | // 超过一周就显示具体日期 17 | let formatter = DateFormatter() 18 | formatter.dateFormat = "MM-dd" 19 | return formatter.string(from: self) 20 | } 21 | 22 | if let hour = components.hour, hour > 0 { 23 | return String(format: NSLocalizedString("time.hours_ago", comment: "Hours ago"), hour) 24 | } 25 | 26 | if let minute = components.minute, minute > 0 { 27 | return String(format: NSLocalizedString("time.minutes_ago", comment: "Minutes ago"), minute) 28 | } 29 | 30 | return NSLocalizedString("time.just_now", comment: "Just now") 31 | } 32 | } 33 | 34 | struct FeedCardView: View { 35 | let feed: Feed 36 | let articleCount: Int 37 | let lastUpdateTime: Date? 38 | let loadingState: ArticlesViewModel.LoadingState 39 | @Environment(\.colorScheme) var colorScheme 40 | @EnvironmentObject var articlesViewModel: ArticlesViewModel 41 | @State private var showingEditSheet = false 42 | @State private var isDeleting = false 43 | @State private var isPressed = false 44 | 45 | private var cardBackground: some View { 46 | colorScheme == .dark 47 | ? Color(UIColor.secondarySystemBackground) 48 | : Color(UIColor.systemBackground) 49 | } 50 | 51 | private var shadowColor: Color { 52 | colorScheme == .dark 53 | ? Color.white 54 | : Color.black 55 | } 56 | 57 | private var iconColor: Color { 58 | if feed.iconColor == "AccentColor" || feed.iconColor == nil { 59 | return .accentColor 60 | } 61 | let colorMap: [String: Color] = [ 62 | "red": .red, 63 | "orange": .orange, 64 | "yellow": .yellow, 65 | "green": .green, 66 | "mint": .mint, 67 | "blue": .blue, 68 | "indigo": .indigo, 69 | "purple": .purple, 70 | "pink": .pink 71 | ] 72 | return colorMap[feed.iconColor!] ?? .accentColor 73 | } 74 | 75 | var body: some View { 76 | NavigationLink(destination: ArticleListView(feed: feed)) { 77 | VStack(alignment: .leading, spacing: 12) { 78 | // 顶部图标和文章数 79 | HStack(spacing: 12) { 80 | Image(systemName: feed.iconName) 81 | .font(.system(size: 28, weight: .medium)) 82 | .foregroundColor(iconColor) 83 | .frame(width: 40, height: 40) 84 | .padding(.vertical, 4) 85 | 86 | Spacer() 87 | 88 | switch loadingState { 89 | case .loading: 90 | ProgressView() 91 | case .failed(let error): 92 | Image(systemName: "exclamationmark.triangle") 93 | .foregroundColor(.red) 94 | .help(error.localizedDescription) 95 | default: 96 | Text("\(articleCount)") 97 | .font(.subheadline.weight(.medium)) 98 | .foregroundColor(.secondary) 99 | .padding(.horizontal, 10) 100 | .padding(.vertical, 4) 101 | .background(Color.secondary.opacity(0.1)) 102 | .clipShape(Capsule()) 103 | } 104 | } 105 | 106 | Spacer() 107 | 108 | // 标题和更新时间 109 | VStack(alignment: .leading, spacing: 6) { 110 | Text(feed.title) 111 | .font(.title3.weight(.semibold)) 112 | .lineLimit(2) 113 | 114 | if let lastUpdate = lastUpdateTime { 115 | HStack(spacing: 4) { 116 | Image(systemName: "clock") 117 | .font(.caption2) 118 | Text(lastUpdate.timeAgoDisplay()) 119 | .font(.caption2) 120 | } 121 | .foregroundColor(.secondary) 122 | } 123 | } 124 | } 125 | .frame(height: 120) 126 | .padding(.horizontal, 16) 127 | .padding(.vertical, 12) 128 | .background( 129 | ZStack { 130 | cardBackground 131 | .opacity(0.98) 132 | 133 | // 添加微妙的渐变背景 134 | LinearGradient( 135 | gradient: Gradient(colors: [ 136 | iconColor.opacity(0.05), 137 | Color.clear 138 | ]), 139 | startPoint: .topLeading, 140 | endPoint: .bottomTrailing 141 | ) 142 | } 143 | ) 144 | .clipShape(RoundedRectangle(cornerRadius: 16)) 145 | .shadow( 146 | color: shadowColor.opacity(colorScheme == .dark ? 0.1 : 0.15), 147 | radius: 10, 148 | x: 0, 149 | y: 2 150 | ) 151 | } 152 | .buttonStyle(.plain) 153 | .opacity(loadingState == .loading ? 0.6 : isDeleting ? 0 : 1.0) 154 | .scaleEffect(isDeleting ? 0.5 : isPressed ? 0.98 : 1.0) 155 | .animation(.spring(response: 0.35, dampingFraction: 0.7), value: isDeleting) 156 | .animation(.spring(response: 0.2, dampingFraction: 0.7), value: isPressed) 157 | .onTapGesture { 158 | withAnimation { 159 | isPressed = true 160 | } 161 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 162 | withAnimation { 163 | isPressed = false 164 | } 165 | } 166 | } 167 | .contextMenu { 168 | Button(action: { 169 | showingEditSheet = true 170 | }) { 171 | Label(NSLocalizedString("feed_edit.title", comment: "Settings"), systemImage: "gear") 172 | } 173 | 174 | Button(role: .destructive, action: { 175 | withAnimation { 176 | isDeleting = true 177 | } 178 | // 延迟删除操作,等待动画完成 179 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { 180 | articlesViewModel.deleteFeed(feed) 181 | } 182 | }) { 183 | Label(NSLocalizedString("feed.delete", comment: "Delete feed"), systemImage: "trash") 184 | } 185 | } 186 | .sheet(isPresented: $showingEditSheet) { 187 | NavigationStack { 188 | FeedEditView(feed: feed) 189 | } 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /RLLM/Services/RSSParser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// RSS解析器,负责解析RSS和Atom格式的Feed 4 | final class RSSParser: NSObject, XMLParserDelegate { 5 | // MARK: - Properties 6 | 7 | /// 当前正在解析的XML元素名称 8 | private var currentElement = "" 9 | 10 | /// 当前文章的标题 11 | private var currentTitle: String? 12 | 13 | /// 当前文章的描述 14 | private var currentDescription: String? 15 | 16 | /// 当前文章的链接 17 | private var currentLink: String? 18 | 19 | /// 当前文章的发布日期 20 | private var currentPubDate: Date? 21 | 22 | /// 当前文章的内容 23 | private var currentContent: String? 24 | 25 | /// 当前文章的作者 26 | private var currentAuthor: String? 27 | 28 | /// 标记是否在 item/entry 元素内 29 | private var isInItem = false 30 | 31 | /// 解析得到的文章列表 32 | private var items: [Article] = [] 33 | 34 | /// Feed的标题 35 | private var feedTitle: String? 36 | 37 | /// Feed的描述 38 | private var feedDescription: String? 39 | 40 | /// 解析过程中的错误 41 | private var parserError: Error? 42 | 43 | // MARK: - Date Formatting 44 | 45 | /// 日期格式化器 46 | private let dateFormatter: DateFormatter 47 | 48 | /// 支持的日期格式列表 49 | private let alternateFormats = [ 50 | // RFC 822, RFC 2822 51 | "EEE, dd MMM yyyy HH:mm:ss Z", 52 | "EEE, dd MMM yyyy HH:mm Z", 53 | "dd MMM yyyy HH:mm:ss Z", 54 | "dd MMM yyyy HH:mm Z", 55 | 56 | // ISO 8601 57 | "yyyy-MM-dd'T'HH:mm:ssZ", 58 | "yyyy-MM-dd'T'HH:mm:ss.SSSZ", 59 | "yyyy-MM-dd'T'HH:mm:ssZZZZZ", 60 | "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", 61 | 62 | // 常见格式 63 | "yyyy-MM-dd HH:mm:ss Z", 64 | "yyyy-MM-dd HH:mm:ss", 65 | "yyyy/MM/dd HH:mm:ss", 66 | "yyyy.MM.dd HH:mm:ss", 67 | "yyyy年MM月dd日 HH:mm:ss", 68 | 69 | // 简单日期格式 70 | "yyyy-MM-dd", 71 | "yyyy/MM/dd", 72 | "yyyy.MM.dd", 73 | "yyyy年MM月dd日" 74 | ] 75 | 76 | // MARK: - Initialization 77 | 78 | override init() { 79 | dateFormatter = DateFormatter() 80 | dateFormatter.locale = Locale(identifier: "en_US_POSIX") 81 | dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 82 | dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" 83 | super.init() 84 | } 85 | 86 | // MARK: - Public Methods 87 | 88 | /// 解析RSS数据 89 | /// - Parameter data: RSS数据 90 | /// - Returns: 包含Feed信息和文章列表的元组 91 | /// - Throws: RSSError.parseError 当解析失败时 92 | /// RSSError.invalidFeed 当Feed数据无效时 93 | func parse(data: Data) throws -> (feed: Feed, articles: [Article]) { 94 | let parser = XMLParser(data: data) 95 | parser.delegate = self 96 | 97 | // 重置状态 98 | resetParserState() 99 | 100 | guard parser.parse() else { 101 | throw parserError ?? RSSError.parseError(parser.parserError ?? NSError()) 102 | } 103 | 104 | guard let title = feedTitle else { 105 | throw RSSError.invalidFeed 106 | } 107 | 108 | let feed = Feed( 109 | title: title, 110 | url: "", // URL将在外部设置 111 | description: feedDescription 112 | ) 113 | 114 | return (feed, items) 115 | } 116 | 117 | // MARK: - Private Methods 118 | 119 | /// 重置解析器状态 120 | private func resetParserState() { 121 | items = [] 122 | feedTitle = nil 123 | feedDescription = nil 124 | parserError = nil 125 | currentElement = "" 126 | isInItem = false 127 | } 128 | 129 | /// 解析日期字符串 130 | /// - Parameter dateString: 日期字符串 131 | /// - Returns: 解析后的Date对象,如果解析失败则返回nil 132 | private func parseDate(_ dateString: String) -> Date? { 133 | // 首先尝试默认格式 134 | if let date = dateFormatter.date(from: dateString) { 135 | return date 136 | } 137 | 138 | // 尝试其他格式 139 | let originalFormat = dateFormatter.dateFormat 140 | defer { dateFormatter.dateFormat = originalFormat } 141 | 142 | for format in alternateFormats { 143 | dateFormatter.dateFormat = format 144 | if let date = dateFormatter.date(from: dateString) { 145 | return date 146 | } 147 | } 148 | 149 | return nil 150 | } 151 | 152 | // MARK: - XMLParserDelegate 153 | 154 | func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { 155 | currentElement = elementName 156 | 157 | if elementName == "item" || elementName == "entry" { 158 | isInItem = true 159 | resetCurrentItemState() 160 | } 161 | 162 | // 处理Atom格式的链接 163 | if elementName == "link", let href = attributeDict["href"] { 164 | currentLink = href 165 | } 166 | } 167 | 168 | /// 重置当前项的状态 169 | private func resetCurrentItemState() { 170 | currentTitle = nil 171 | currentDescription = nil 172 | currentLink = nil 173 | currentPubDate = nil 174 | currentContent = nil 175 | currentAuthor = nil 176 | } 177 | 178 | func parser(_ parser: XMLParser, foundCharacters string: String) { 179 | let content = string.trimmingCharacters(in: .whitespacesAndNewlines) 180 | 181 | guard !content.isEmpty else { return } 182 | 183 | switch currentElement { 184 | case "title": 185 | if isInItem { 186 | currentTitle = (currentTitle ?? "") + content 187 | } else { 188 | feedTitle = (feedTitle ?? "") + content 189 | } 190 | 191 | case "description", "summary", "subtitle": 192 | if isInItem { 193 | currentDescription = (currentDescription ?? "") + content 194 | } else { 195 | feedDescription = (feedDescription ?? "") + content 196 | } 197 | 198 | case "link": 199 | if currentLink == nil { 200 | currentLink = content 201 | } 202 | 203 | case "pubDate", "published", "updated", "lastBuildDate", "dc:date": 204 | if currentPubDate == nil { 205 | currentPubDate = parseDate(content) 206 | } 207 | 208 | case "content:encoded", "content": 209 | currentContent = (currentContent ?? "") + content 210 | 211 | case "author", "dc:creator": 212 | if currentAuthor == nil { 213 | currentAuthor = content 214 | } 215 | 216 | default: 217 | break 218 | } 219 | } 220 | 221 | func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { 222 | if elementName == "item" || elementName == "entry" { 223 | isInItem = false 224 | let article = createArticle() 225 | items.append(article) 226 | } 227 | } 228 | 229 | /// 创建文章对象 230 | /// - Returns: 根据当前解析状态创建的Article对象 231 | private func createArticle() -> Article { 232 | Article( 233 | title: currentTitle ?? "无标题", 234 | content: currentContent ?? currentDescription ?? "", 235 | url: currentLink ?? "", 236 | publishDate: currentPubDate ?? Date(), 237 | feedTitle: feedTitle ?? "未知源", 238 | author: currentAuthor 239 | ) 240 | } 241 | 242 | func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { 243 | parserError = parseError 244 | } 245 | } -------------------------------------------------------------------------------- /RLLM/Views/AIInsights/ArticleInsightView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 文章AI洞察视图 4 | /// 展示单篇文章的AI分析结果 5 | struct ArticleInsightView: View { 6 | // MARK: - Properties 7 | 8 | /// 文章内容 9 | let content: String 10 | 11 | /// 文章ID 12 | let articleId: String 13 | 14 | /// AI洞察视图模型 15 | @StateObject private var viewModel = AIInsightsViewModel() 16 | 17 | // MARK: - Initialization 18 | 19 | init(content: String, articleId: String) { 20 | self.content = content 21 | self.articleId = articleId 22 | } 23 | 24 | // MARK: - Body 25 | 26 | var body: some View { 27 | ScrollView { 28 | VStack(alignment: .leading, spacing: 20) { 29 | if viewModel.isAnalyzing { 30 | ProgressView(NSLocalizedString("article_insight.analyzing", comment: "Analyzing progress")) 31 | .frame(maxWidth: .infinity, maxHeight: .infinity) 32 | } else if let insight = viewModel.articleInsight { 33 | insightContent(insight) 34 | } else if let error = viewModel.error { 35 | ErrorView( 36 | error: error, 37 | retryAction: { 38 | Task { 39 | _ = await viewModel.analyzeArticle(content, articleId: articleId, forceRefresh: true) 40 | } 41 | } 42 | ) 43 | .frame(maxWidth: .infinity, maxHeight: .infinity) 44 | } else { 45 | VStack(spacing: 16) { 46 | Image(systemName: "wand.and.stars") 47 | .font(.largeTitle) 48 | .foregroundColor(.accentColor) 49 | Text(NSLocalizedString("article_insight.click_to_start", comment: "Click to start analysis")) 50 | .foregroundColor(.secondary) 51 | Button(NSLocalizedString("article_insight.start", comment: "Start analysis")) { 52 | Task { 53 | _ = await viewModel.analyzeArticle(content, articleId: articleId) 54 | HapticManager.shared.success() 55 | } 56 | } 57 | .buttonStyle(.bordered) 58 | } 59 | .frame(maxWidth: .infinity, maxHeight: .infinity) 60 | .padding() 61 | } 62 | } 63 | .padding() 64 | .frame(maxWidth: .infinity, minHeight: 300) 65 | } 66 | .navigationTitle(NSLocalizedString("article.ai_insight", comment: "AI Deep Insight")) 67 | .navigationBarTitleDisplayMode(.inline) 68 | .toolbar { 69 | ToolbarItem(placement: .primaryAction) { 70 | Button { 71 | Task { 72 | _ = await viewModel.analyzeArticle(content, articleId: articleId, forceRefresh: true) 73 | } 74 | } label: { 75 | Image(systemName: "arrow.clockwise") 76 | } 77 | .disabled(viewModel.isAnalyzing) 78 | } 79 | } 80 | .task { 81 | // 视图出现时检查缓存 82 | if InsightCache.shared.has(for: articleId) { 83 | _ = await viewModel.analyzeArticle(content, articleId: articleId) 84 | } 85 | } 86 | } 87 | 88 | // MARK: - Private Views 89 | 90 | /// 构建洞察内容视图 91 | /// - Parameter insight: 洞察结果 92 | /// - Returns: 洞察内容视图 93 | private func insightContent(_ insight: ArticleInsight) -> some View { 94 | VStack(alignment: .leading, spacing: 20) { 95 | // 主题标签 96 | VStack(alignment: .leading, spacing: 12) { 97 | Label(NSLocalizedString("article_insight.topic_tags", comment: "Topic tags"), systemImage: "tag") 98 | .font(.headline) 99 | .padding(.bottom, 8) 100 | ScrollView(.horizontal, showsIndicators: false) { 101 | HStack(spacing: 8) { 102 | ForEach(insight.topics, id: \.self) { topic in 103 | Text(topic) 104 | .font(.subheadline) 105 | .foregroundColor(.accentColor) 106 | .padding(.horizontal, 12) 107 | .padding(.vertical, 6) 108 | .background(Color.accentColor.opacity(0.1)) 109 | .clipShape(Capsule()) 110 | .onTapGesture { 111 | HapticManager.shared.selection() 112 | } 113 | } 114 | } 115 | .padding(.horizontal, 2) 116 | } 117 | } 118 | .padding(.vertical, 4) 119 | 120 | Divider() 121 | 122 | // 核心摘要 123 | VStack(alignment: .leading, spacing: 12) { 124 | Label(NSLocalizedString("article_insight.core_summary", comment: "Core summary"), systemImage: "text.justify") 125 | .font(.headline) 126 | .padding(.bottom, 4) 127 | Text(insight.summary) 128 | .font(.body) 129 | } 130 | 131 | Divider() 132 | 133 | // 关键观点 134 | VStack(alignment: .leading, spacing: 12) { 135 | Label(NSLocalizedString("article_insight.key_points", comment: "Key points"), systemImage: "list.bullet") 136 | .font(.headline) 137 | .padding(.bottom, 4) 138 | ForEach(insight.keyPoints, id: \.self) { point in 139 | HStack(alignment: .top, spacing: 8) { 140 | Image(systemName: "circle.fill") 141 | .font(.system(size: 6)) 142 | .padding(.top, 7) 143 | Text(point) 144 | } 145 | } 146 | } 147 | 148 | Divider() 149 | 150 | // 情感倾向 151 | VStack(alignment: .leading, spacing: 12) { 152 | Label(NSLocalizedString("article_insight.sentiment", comment: "Sentiment"), systemImage: "heart") 153 | .font(.headline) 154 | .padding(.bottom, 4) 155 | Text(insight.sentiment) 156 | .font(.body) 157 | } 158 | 159 | // 背景补充(如果有) 160 | if let backgroundInfo = insight.backgroundInfo { 161 | Divider() 162 | VStack(alignment: .leading, spacing: 12) { 163 | Label(NSLocalizedString("article_insight.background", comment: "Background"), systemImage: "info.circle") 164 | .font(.headline) 165 | .padding(.bottom, 4) 166 | Text(backgroundInfo) 167 | .font(.body) 168 | } 169 | } 170 | } 171 | .padding(.horizontal) 172 | } 173 | } 174 | 175 | // MARK: - FlowLayout 176 | 177 | /// 流式布局视图 178 | /// 用于展示标签等需要自动换行的内容 179 | private struct FlowLayout: Layout { 180 | let spacing: CGFloat 181 | 182 | func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { 183 | let result = FlowResult(in: proposal.width ?? 0, subviews: subviews, spacing: spacing) 184 | return result.size 185 | } 186 | 187 | func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { 188 | let result = FlowResult(in: bounds.width, subviews: subviews, spacing: spacing) 189 | for (index, frame) in result.frames.enumerated() { 190 | subviews[index].place(at: frame.origin, proposal: ProposedViewSize(frame.size)) 191 | } 192 | } 193 | 194 | /// 计算流式布局结果 195 | private struct FlowResult { 196 | var size: CGSize = .zero 197 | var frames: [CGRect] = [] 198 | 199 | init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) { 200 | var currentX: CGFloat = 0 201 | var currentY: CGFloat = 0 202 | var lineHeight: CGFloat = 0 203 | 204 | for subview in subviews { 205 | let viewSize = subview.sizeThatFits(.unspecified) 206 | 207 | if currentX + viewSize.width > maxWidth { 208 | // 换行 209 | currentX = 0 210 | currentY += lineHeight + spacing 211 | lineHeight = 0 212 | } 213 | 214 | frames.append(CGRect(x: currentX, y: currentY, width: viewSize.width, height: viewSize.height)) 215 | currentX += viewSize.width + spacing 216 | lineHeight = max(lineHeight, viewSize.height) 217 | } 218 | 219 | size = CGSize(width: maxWidth, height: currentY + lineHeight) 220 | } 221 | } 222 | } -------------------------------------------------------------------------------- /RLLM/Services/CacheManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 缓存管理器错误类型 4 | enum CacheError: LocalizedError { 5 | /// 创建缓存目录失败 6 | case directoryCreationFailed(Error) 7 | /// 写入缓存失败 8 | case writeFailed(Error) 9 | /// 读取缓存失败 10 | case readFailed(Error) 11 | /// 缓存已满 12 | case cacheFull 13 | /// 文件过大 14 | case fileTooLarge 15 | 16 | var errorDescription: String? { 17 | switch self { 18 | case .directoryCreationFailed(let error): 19 | return "创建缓存目录失败:\(error.localizedDescription)" 20 | case .writeFailed(let error): 21 | return "写入缓存失败:\(error.localizedDescription)" 22 | case .readFailed(let error): 23 | return "读取缓存失败:\(error.localizedDescription)" 24 | case .cacheFull: 25 | return "缓存空间已满" 26 | case .fileTooLarge: 27 | return "文件超过大小限制" 28 | } 29 | } 30 | } 31 | 32 | /// 通用缓存管理器 33 | class CacheManager { 34 | /// 缓存目录名称 35 | private let directoryName: String 36 | 37 | /// 缓存目录URL 38 | private var cacheDirectory: URL { 39 | let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) 40 | return paths[0].appendingPathComponent(directoryName) 41 | } 42 | 43 | /// 缓存条目信息文件名 44 | private let infoFileName = "cache_info.json" 45 | 46 | /// 缓存条目信息 47 | private var entryInfos: [String: CacheEntryInfo] = [:] 48 | 49 | /// 缓存统计信息 50 | private var stats = CacheStats( 51 | entryCount: 0, 52 | totalSize: 0, 53 | expiredCount: 0, 54 | oldestEntryDate: nil, 55 | newestEntryDate: nil, 56 | averageAge: nil, 57 | hitRate: 0 58 | ) 59 | 60 | /// 缓存命中次数 61 | private var hits: Int = 0 62 | 63 | /// 缓存访问总次数 64 | private var totalAccesses: Int = 0 65 | 66 | /// 初始化缓存管理器 67 | /// - Parameter directoryName: 缓存目录名称 68 | init(directoryName: String) { 69 | self.directoryName = directoryName 70 | createCacheDirectoryIfNeeded() 71 | loadCacheInfo() 72 | } 73 | 74 | /// 创建缓存目录(如果不存在) 75 | private func createCacheDirectoryIfNeeded() { 76 | let fileManager = FileManager.default 77 | if !fileManager.fileExists(atPath: cacheDirectory.path) { 78 | do { 79 | try fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) 80 | } catch { 81 | print("Error creating cache directory: \(error)") 82 | } 83 | } 84 | } 85 | 86 | /// 加载缓存信息 87 | private func loadCacheInfo() { 88 | let infoURL = cacheDirectory.appendingPathComponent(infoFileName) 89 | if let data = try? Data(contentsOf: infoURL) { 90 | entryInfos = (try? JSONDecoder().decode([String: CacheEntryInfo].self, from: data)) ?? [:] 91 | updateStats() 92 | } 93 | } 94 | 95 | /// 保存缓存信息 96 | private func saveCacheInfo() { 97 | let infoURL = cacheDirectory.appendingPathComponent(infoFileName) 98 | if let data = try? JSONEncoder().encode(entryInfos) { 99 | try? data.write(to: infoURL) 100 | } 101 | } 102 | 103 | /// 更新缓存统计信息 104 | private func updateStats() { 105 | let now = Date() 106 | var totalSize: Int64 = 0 107 | var expiredCount = 0 108 | var oldestDate: Date? 109 | var newestDate: Date? 110 | var totalAge: TimeInterval = 0 111 | 112 | for (_, info) in entryInfos { 113 | totalSize += info.fileSize 114 | if info.isExpired() { 115 | expiredCount += 1 116 | } 117 | 118 | if let oldest = oldestDate { 119 | oldestDate = info.createdAt < oldest ? info.createdAt : oldest 120 | } else { 121 | oldestDate = info.createdAt 122 | } 123 | 124 | if let newest = newestDate { 125 | newestDate = info.createdAt > newest ? info.createdAt : newest 126 | } else { 127 | newestDate = info.createdAt 128 | } 129 | 130 | totalAge += now.timeIntervalSince(info.createdAt) 131 | } 132 | 133 | let entryCount = entryInfos.count 134 | let averageAge = entryCount > 0 ? totalAge / Double(entryCount) : nil 135 | let hitRate = totalAccesses > 0 ? Double(hits) / Double(totalAccesses) : 0 136 | 137 | stats = CacheStats( 138 | entryCount: entryCount, 139 | totalSize: totalSize, 140 | expiredCount: expiredCount, 141 | oldestEntryDate: oldestDate, 142 | newestEntryDate: newestDate, 143 | averageAge: averageAge, 144 | hitRate: hitRate 145 | ) 146 | } 147 | 148 | /// 清理过期和超量的缓存 149 | private func cleanup() { 150 | // 清理过期缓存 151 | let expiredKeys = entryInfos.filter { $0.value.isExpired() }.map { $0.key } 152 | for key in expiredKeys { 153 | removeEntry(for: key) 154 | } 155 | 156 | // 如果仍然超过大小限制,按最后访问时间清理 157 | if stats.totalSize > CacheConfig.maxTotalSize { 158 | let sortedEntries = entryInfos.sorted { $0.value.lastAccessedAt < $1.value.lastAccessedAt } 159 | for entry in sortedEntries { 160 | if stats.totalSize <= CacheConfig.maxTotalSize { 161 | break 162 | } 163 | removeEntry(for: entry.key) 164 | } 165 | } 166 | 167 | // 如果超过数量限制,继续清理 168 | if stats.entryCount > CacheConfig.maxEntries { 169 | let sortedEntries = entryInfos.sorted { $0.value.lastAccessedAt < $1.value.lastAccessedAt } 170 | let excessCount = stats.entryCount - CacheConfig.maxEntries 171 | for i in 0.. CacheStats { 191 | updateStats() 192 | return stats 193 | } 194 | 195 | /// 清除所有缓存 196 | func clearAll() { 197 | let fileManager = FileManager.default 198 | try? fileManager.removeItem(at: cacheDirectory) 199 | createCacheDirectoryIfNeeded() 200 | entryInfos.removeAll() 201 | updateStats() 202 | saveCacheInfo() 203 | } 204 | 205 | /// 写入缓存 206 | /// - Parameters: 207 | /// - data: 要缓存的数据 208 | /// - key: 缓存key 209 | /// - Throws: CacheError 210 | func write(_ data: Data, for key: String) throws { 211 | // 检查文件大小 212 | let fileSize = Int64(data.count) 213 | if fileSize > CacheConfig.maxFileSize { 214 | throw CacheError.fileTooLarge 215 | } 216 | 217 | // 检查总缓存大小 218 | if stats.totalSize + fileSize > CacheConfig.maxTotalSize { 219 | cleanup() 220 | if stats.totalSize + fileSize > CacheConfig.maxTotalSize { 221 | throw CacheError.cacheFull 222 | } 223 | } 224 | 225 | // 写入文件 226 | let fileURL = cacheDirectory.appendingPathComponent(key) 227 | do { 228 | try data.write(to: fileURL) 229 | let now = Date() 230 | entryInfos[key] = CacheEntryInfo( 231 | createdAt: now, 232 | lastAccessedAt: now, 233 | fileSize: fileSize 234 | ) 235 | updateStats() 236 | saveCacheInfo() 237 | } catch { 238 | throw CacheError.writeFailed(error) 239 | } 240 | } 241 | 242 | /// 读取缓存 243 | /// - Parameter key: 缓存key 244 | /// - Returns: 缓存的数据 245 | /// - Throws: CacheError 246 | func read(for key: String) throws -> Data { 247 | totalAccesses += 1 248 | 249 | guard let info = entryInfos[key], !info.isExpired() else { 250 | if entryInfos[key] != nil { 251 | removeEntry(for: key) 252 | } 253 | throw CacheError.readFailed(NSError(domain: "Cache", code: -1, userInfo: [NSLocalizedDescriptionKey: "Cache entry expired or not found"])) 254 | } 255 | 256 | let fileURL = cacheDirectory.appendingPathComponent(key) 257 | do { 258 | let data = try Data(contentsOf: fileURL) 259 | hits += 1 260 | entryInfos[key]?.lastAccessedAt = Date() 261 | saveCacheInfo() 262 | return data 263 | } catch { 264 | throw CacheError.readFailed(error) 265 | } 266 | } 267 | 268 | /// 检查是否存在缓存 269 | /// - Parameter key: 缓存key 270 | /// - Returns: 是否存在有效缓存 271 | func has(for key: String) -> Bool { 272 | guard let info = entryInfos[key], !info.isExpired() else { 273 | if entryInfos[key] != nil { 274 | removeEntry(for: key) 275 | } 276 | return false 277 | } 278 | return true 279 | } 280 | } -------------------------------------------------------------------------------- /RLLM/Views/Quotes/QuotesListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct QuotesListView: View { 4 | @EnvironmentObject private var viewModel: QuotesViewModel 5 | @State private var isEditMode: Bool = false 6 | @State private var isAllSelected: Bool = false 7 | @State private var isExporting: Bool = false 8 | 9 | var body: some View { 10 | List { 11 | Group { 12 | if viewModel.quotes.isEmpty { 13 | EmptyQuotesView() 14 | } else { 15 | QuotesList(quotes: viewModel.quotes, isEditMode: $isEditMode, isAllSelected: $isAllSelected) 16 | } 17 | } 18 | } 19 | .navigationTitle(NSLocalizedString("quotes.title", comment: "Quotes title")) 20 | .listStyle(.plain) 21 | .scrollContentBackground(.hidden) 22 | .background(Color(.systemGroupedBackground)) 23 | .overlay { 24 | if isExporting { 25 | ZStack { 26 | Color.black.opacity(0.3) 27 | VStack(spacing: 16) { 28 | ProgressView() 29 | .scaleEffect(1.5) 30 | .tint(.white) 31 | Text(NSLocalizedString("export.generating", comment: "Generating newspaper style")) 32 | .foregroundColor(.white) 33 | } 34 | } 35 | .ignoresSafeArea() 36 | } 37 | } 38 | .toolbar { 39 | if !viewModel.quotes.isEmpty { 40 | ToolbarItem(placement: .topBarTrailing) { 41 | Button(isEditMode ? NSLocalizedString("quotes.done", comment: "Done") : NSLocalizedString("quotes.edit", comment: "Edit")) { 42 | withAnimation { 43 | isEditMode.toggle() 44 | if !isEditMode { 45 | // 退出编辑模式时重置选择状态 46 | isAllSelected = false 47 | viewModel.resetSelection() 48 | } 49 | } 50 | } 51 | } 52 | 53 | if isEditMode { 54 | ToolbarItem(placement: .topBarLeading) { 55 | Button(isAllSelected ? NSLocalizedString("quotes.deselect_all", comment: "Deselect All") : NSLocalizedString("quotes.select_all", comment: "Select All")) { 56 | withAnimation { 57 | isAllSelected.toggle() 58 | viewModel.toggleSelectAll(isAllSelected) 59 | } 60 | } 61 | } 62 | 63 | ToolbarItem(placement: .bottomBar) { 64 | if viewModel.hasSelectedQuotes { 65 | HStack { 66 | Spacer() 67 | 68 | Button(role: .destructive) { 69 | viewModel.deleteSelectedQuotes() 70 | if viewModel.quotes.isEmpty { 71 | isEditMode = false 72 | } 73 | } label: { 74 | HStack { 75 | Image(systemName: "trash") 76 | Text(NSLocalizedString("quotes.delete_selected", comment: "Delete Selected")) 77 | } 78 | } 79 | .buttonStyle(.borderedProminent) 80 | 81 | Spacer() 82 | .frame(width: 20) 83 | 84 | Button { 85 | exportSelectedQuotes() 86 | } label: { 87 | HStack { 88 | Image(systemName: "square.and.arrow.up") 89 | Text(NSLocalizedString("share.button", comment: "Share")) 90 | } 91 | } 92 | .buttonStyle(.borderedProminent) 93 | .disabled(isExporting) 94 | 95 | Spacer() 96 | } 97 | .padding(.horizontal) 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | private func exportSelectedQuotes() { 106 | let selectedQuotes = viewModel.quotes.filter { $0.isSelected } 107 | 108 | withAnimation { 109 | isExporting = true 110 | } 111 | 112 | ExportManager.shared.generateNewspaperImage(from: selectedQuotes) { result in 113 | withAnimation { 114 | isExporting = false 115 | } 116 | 117 | switch result { 118 | case .success(let image): 119 | // 显示成功提示 120 | ToastManager.shared.showSuccess( 121 | NSLocalizedString("export.success.title", comment: "Export success"), 122 | message: NSLocalizedString("export.success.message", comment: "Export success message") 123 | ) 124 | 125 | // 显示分享菜单 126 | let activityVC = UIActivityViewController( 127 | activityItems: [image], 128 | applicationActivities: nil 129 | ) 130 | 131 | if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 132 | let window = windowScene.windows.first, 133 | let viewController = window.rootViewController { 134 | viewController.present(activityVC, animated: true) 135 | } 136 | 137 | case .failure(let error): 138 | // 显示错误提示 139 | ToastManager.shared.showError( 140 | NSLocalizedString("export.error.title", comment: "Export error"), 141 | message: String(format: NSLocalizedString("export.error.message", comment: "Export error message"), 142 | error.localizedDescription) 143 | ) 144 | } 145 | } 146 | } 147 | } 148 | 149 | private struct EmptyQuotesView: View { 150 | var body: some View { 151 | ContentUnavailableView { 152 | Label(NSLocalizedString("quotes.save_quote", comment: "Save quotes title"), systemImage: "quote.bubble") 153 | .font(.title2) 154 | } description: { 155 | VStack(spacing: 12) { 156 | Text(NSLocalizedString("quotes.save_instruction", comment: "Save quotes instruction")) 157 | Text(NSLocalizedString("quotes.saved_display", comment: "Saved quotes display")) 158 | .foregroundColor(.secondary) 159 | } 160 | } 161 | .listRowBackground(Color.clear) 162 | .listRowInsets(EdgeInsets()) 163 | .listRowSeparator(.hidden) 164 | .frame(maxWidth: .infinity, maxHeight: .infinity) 165 | .padding(.top, 100) 166 | } 167 | } 168 | 169 | private struct QuotesList: View { 170 | let quotes: [Quote] 171 | @Binding var isEditMode: Bool 172 | @Binding var isAllSelected: Bool 173 | @EnvironmentObject private var viewModel: QuotesViewModel 174 | 175 | var body: some View { 176 | ForEach(quotes) { quote in 177 | HStack { 178 | if isEditMode { 179 | Button { 180 | withAnimation { 181 | viewModel.toggleQuoteSelection(quote) 182 | isAllSelected = viewModel.areAllQuotesSelected 183 | } 184 | } label: { 185 | Image(systemName: quote.isSelected ? "checkmark.circle.fill" : "circle") 186 | .foregroundColor(quote.isSelected ? .accentColor : .secondary) 187 | .imageScale(.large) 188 | } 189 | .buttonStyle(.plain) 190 | } 191 | 192 | QuoteRowView(quote: quote) 193 | .contentShape(Rectangle()) 194 | .background( 195 | NavigationLink(destination: QuoteDetailView(quote: quote)) { 196 | EmptyView() 197 | } 198 | .opacity(0) 199 | .disabled(isEditMode) 200 | ) 201 | } 202 | .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) 203 | .listRowSeparator(.hidden) 204 | .listRowBackground(Color.clear) 205 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { 206 | if !isEditMode { 207 | Button(role: .destructive) { 208 | if let index = viewModel.quotes.firstIndex(where: { $0.id == quote.id }) { 209 | viewModel.deleteQuotes(at: IndexSet([index])) 210 | } 211 | } label: { 212 | VStack { 213 | Image(systemName: "trash") 214 | .font(.title2) 215 | Text(NSLocalizedString("quotes.delete", comment: "Delete quote")) 216 | .font(.caption) 217 | } 218 | .frame(width: 60) 219 | .padding(.vertical, 12) 220 | } 221 | } 222 | } 223 | } 224 | } 225 | } -------------------------------------------------------------------------------- /RLLM/Services/ExportManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | /// 导出管理器的错误类型 5 | enum ExportError: LocalizedError { 6 | case emptyContent 7 | case fileCreationFailed 8 | case writeError(Error) 9 | case imageGenerationFailed 10 | 11 | var errorDescription: String? { 12 | switch self { 13 | case .emptyContent: 14 | return NSLocalizedString("export.error.empty_content", comment: "No content to export") 15 | case .fileCreationFailed: 16 | return NSLocalizedString("export.error.file_creation", comment: "Failed to create export file") 17 | case .writeError(let error): 18 | return String(format: NSLocalizedString("export.error.write", comment: "Failed to write content"), error.localizedDescription) 19 | case .imageGenerationFailed: 20 | return NSLocalizedString("export.error.image_generation", comment: "Failed to generate image") 21 | } 22 | } 23 | } 24 | 25 | /// 导出管理器 26 | /// 负责将选中的收藏内容导出为文本文件或图片 27 | class ExportManager { 28 | /// 单例实例 29 | static let shared = ExportManager() 30 | 31 | private init() {} 32 | 33 | 34 | 35 | // MARK: - 图片导出 36 | 37 | /// 生成报纸风格的图片 38 | /// - Parameters: 39 | /// - quotes: 要导出的收藏数组 40 | /// - completion: 完成回调,返回生成的图片或错误 41 | func generateNewspaperImage( 42 | from quotes: [Quote], 43 | completion: @escaping (Result) -> Void 44 | ) { 45 | // 检查是否有内容 46 | guard !quotes.isEmpty else { 47 | completion(.failure(ExportError.emptyContent)) 48 | return 49 | } 50 | 51 | // 计算内容总高度 52 | let contentWidth: CGFloat = 800 // 基础宽度 53 | let titleHeight: CGFloat = 120 // 标题区域高度 54 | let dateHeight: CGFloat = 30 // 日期区域高度 55 | let topPadding: CGFloat = 40 // 顶部边距 56 | let bottomPadding: CGFloat = 40 // 底部边距 57 | let quoteSpacing: CGFloat = 20 // 引用之间的间距 58 | 59 | // 计算每个引用视图的高度 60 | var totalHeight: CGFloat = titleHeight + dateHeight + topPadding + bottomPadding 61 | let quoteViews = quotes.map { createQuoteView($0, width: contentWidth - 120) } 62 | totalHeight += quoteViews.reduce(0) { $0 + $1.bounds.height } 63 | totalHeight += CGFloat(quotes.count - 1) * quoteSpacing 64 | 65 | // 创建容器视图 66 | let containerView = UIView(frame: CGRect(x: 0, y: 0, width: contentWidth, height: totalHeight)) 67 | containerView.backgroundColor = UIColor(red: 253/255, green: 246/255, blue: 227/255, alpha: 1.0) // 复古米黄色背景 68 | 69 | // 添加纸张纹理效果 70 | let noiseLayer = CALayer() 71 | noiseLayer.frame = containerView.bounds 72 | noiseLayer.backgroundColor = UIColor.black.cgColor 73 | noiseLayer.opacity = 0.03 74 | containerView.layer.addSublayer(noiseLayer) 75 | 76 | // 创建主标题容器 77 | let titleContainer = UIView(frame: CGRect(x: 60, y: topPadding, width: containerView.bounds.width - 120, height: titleHeight)) 78 | titleContainer.backgroundColor = .clear 79 | 80 | // 添加装饰性边框 81 | let borderLayer = CAShapeLayer() 82 | borderLayer.strokeColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 0.8).cgColor 83 | borderLayer.fillColor = nil 84 | borderLayer.lineWidth = 2 85 | borderLayer.path = UIBezierPath(roundedRect: titleContainer.bounds, cornerRadius: 4).cgPath 86 | 87 | // 添加双线边框效果 88 | let innerBorderLayer = CAShapeLayer() 89 | innerBorderLayer.strokeColor = borderLayer.strokeColor 90 | innerBorderLayer.fillColor = nil 91 | innerBorderLayer.lineWidth = 1 92 | innerBorderLayer.path = UIBezierPath(roundedRect: titleContainer.bounds.insetBy(dx: 6, dy: 6), cornerRadius: 2).cgPath 93 | 94 | titleContainer.layer.addSublayer(borderLayer) 95 | titleContainer.layer.addSublayer(innerBorderLayer) 96 | 97 | // 创建主标题 98 | let titleLabel = UILabel() 99 | titleLabel.text = NSLocalizedString("export.newspaper.title", comment: "Newspaper title") 100 | titleLabel.font = UIFont(name: "TimesNewRomanPS-BoldMT", size: 48) 101 | titleLabel.textAlignment = .center 102 | titleLabel.frame = titleContainer.bounds.insetBy(dx: 20, dy: 20) 103 | titleLabel.backgroundColor = .clear 104 | titleLabel.textColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1.0) // 固定使用深色文本 105 | titleContainer.addSubview(titleLabel) 106 | containerView.addSubview(titleContainer) 107 | 108 | // 添加日期 109 | let dateLabel = UILabel() 110 | dateLabel.text = String(format: NSLocalizedString("export.newspaper.date", comment: "Publish date"), 111 | DateFormatter.yyyyMMdd.string(from: Date())) 112 | dateLabel.font = UIFont(name: "TimesNewRomanPS-ItalicMT", size: 16) 113 | dateLabel.textAlignment = .center 114 | dateLabel.textColor = UIColor(red: 0.4, green: 0.4, blue: 0.4, alpha: 1.0) 115 | dateLabel.frame = CGRect(x: 60, y: titleContainer.frame.maxY + 16, width: containerView.bounds.width - 120, height: dateHeight) 116 | dateLabel.backgroundColor = .clear 117 | containerView.addSubview(dateLabel) 118 | 119 | // 添加引用视图 120 | var currentY = dateLabel.frame.maxY + 30 121 | for (index, quoteView) in quoteViews.enumerated() { 122 | quoteView.frame.origin = CGPoint(x: 60, y: currentY) 123 | containerView.addSubview(quoteView) 124 | currentY = quoteView.frame.maxY + (index < quoteViews.count - 1 ? quoteSpacing : 0) 125 | } 126 | 127 | // 生成图片 128 | DispatchQueue.main.async { 129 | // 确保所有子视图都已布局 130 | containerView.layoutIfNeeded() 131 | 132 | // 创建图片上下文,背景透明 133 | UIGraphicsBeginImageContextWithOptions(containerView.bounds.size, false, 0.0) 134 | defer { UIGraphicsEndImageContext() } 135 | 136 | guard let context = UIGraphicsGetCurrentContext() else { 137 | completion(.failure(ExportError.imageGenerationFailed)) 138 | return 139 | } 140 | 141 | // 渲染视图层级 142 | containerView.layer.render(in: context) 143 | 144 | guard let image = UIGraphicsGetImageFromCurrentImageContext() else { 145 | completion(.failure(ExportError.imageGenerationFailed)) 146 | return 147 | } 148 | 149 | completion(.success(image)) 150 | } 151 | } 152 | 153 | /// 创建单条引用视图 154 | /// - Parameters: 155 | /// - quote: 引用内容 156 | /// - width: 视图宽度 157 | /// - Returns: 包含引用内容的视图 158 | private func createQuoteView(_ quote: Quote, width: CGFloat) -> UIView { 159 | let container = UIView() 160 | container.backgroundColor = .clear 161 | 162 | // 添加内容 163 | let contentLabel = UILabel() 164 | contentLabel.text = quote.content.removingHTMLTags() 165 | contentLabel.font = UIFont(name: "TimesNewRomanPS-BoldMT", size: 24) 166 | contentLabel.numberOfLines = 0 167 | contentLabel.textColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1.0) 168 | 169 | // 计算内容高度 170 | let contentSize = contentLabel.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) 171 | contentLabel.frame = CGRect(x: 0, y: 0, width: width, height: contentSize.height) 172 | container.addSubview(contentLabel) 173 | 174 | // 添加来源信息 175 | let metaLabel = UILabel() 176 | metaLabel.text = "—— " + quote.articleTitle 177 | metaLabel.font = UIFont(name: "TimesNewRomanPS-ItalicMT", size: 14) 178 | metaLabel.textColor = UIColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0) 179 | metaLabel.textAlignment = .right 180 | metaLabel.frame = CGRect(x: 0, y: contentLabel.frame.maxY + 12, width: width, height: 20) 181 | container.addSubview(metaLabel) 182 | 183 | // 添加装饰性分隔线 184 | let separatorView = UIView(frame: CGRect(x: width * 0.1, 185 | y: metaLabel.frame.maxY + 24, 186 | width: width * 0.8, 187 | height: 1)) 188 | separatorView.backgroundColor = UIColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 0.5) 189 | container.addSubview(separatorView) 190 | 191 | // 设置容器大小 192 | container.frame = CGRect(x: 0, y: 0, 193 | width: width, 194 | height: separatorView.frame.maxY) 195 | 196 | return container 197 | } 198 | 199 | // MARK: - OPML Export 200 | 201 | /// 导出订阅源列表为OPML格式 202 | /// - Parameter feeds: 要导出的订阅源列表 203 | /// - Returns: 导出文件的URL 204 | /// - Throws: ExportError 205 | func exportOPML(_ feeds: [Feed]) throws -> URL { 206 | // 检查是否有内容需要导出 207 | guard !feeds.isEmpty else { 208 | throw ExportError.emptyContent 209 | } 210 | 211 | // 生成OPML内容 212 | var opml = """ 213 | 214 | 215 | 216 | RLLM Feeds 217 | \(Date().ISO8601Format()) 218 | 219 | 220 | """ 221 | 222 | // 添加每个订阅源 223 | for feed in feeds { 224 | let escapedTitle = feed.title.replacingOccurrences(of: "\"", with: """) 225 | let escapedURL = feed.url.replacingOccurrences(of: "\"", with: """) 226 | opml += """ 227 | 228 | """ 229 | } 230 | 231 | opml += """ 232 | 233 | 234 | """ 235 | 236 | // 创建临时文件 237 | let fileName = "RLLM_Feeds_\(DateFormatter.yyyyMMdd.string(from: Date())).opml" 238 | let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) 239 | 240 | do { 241 | // 写入文件 242 | try opml.write(to: tempURL, atomically: true, encoding: .utf8) 243 | return tempURL 244 | } catch { 245 | throw ExportError.writeError(error) 246 | } 247 | } 248 | } -------------------------------------------------------------------------------- /RLLM/Views/Settings/ReadingHistoryView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ReadingHistoryView: View { 4 | @StateObject private var historyManager = ReadingHistoryManager.shared 5 | @State private var selectedTimeRange: TimeRange = .week 6 | 7 | enum TimeRange { 8 | case day, week, month 9 | 10 | var title: String { 11 | switch self { 12 | case .day: return NSLocalizedString("reading_history.today", comment: "Today") 13 | case .week: return NSLocalizedString("reading_history.this_week", comment: "This week") 14 | case .month: return NSLocalizedString("reading_history.this_month", comment: "This month") 15 | } 16 | } 17 | } 18 | 19 | var body: some View { 20 | List { 21 | Section { 22 | Picker(NSLocalizedString("reading_history.time_range", comment: "Time range"), selection: $selectedTimeRange) { 23 | Text(NSLocalizedString("reading_history.today", comment: "Today")).tag(TimeRange.day) 24 | Text(NSLocalizedString("reading_history.this_week", comment: "This week")).tag(TimeRange.week) 25 | Text(NSLocalizedString("reading_history.this_month", comment: "This month")).tag(TimeRange.month) 26 | } 27 | .pickerStyle(.segmented) 28 | .padding(.vertical, 8) 29 | } 30 | 31 | Section(NSLocalizedString("reading_history.statistics", comment: "Reading statistics")) { 32 | VStack(spacing: 16) { 33 | HStack { 34 | StatCard( 35 | title: NSLocalizedString("reading_history.reading_time", comment: "Reading time"), 36 | value: formattedReadingTime, 37 | icon: "clock.fill" 38 | ) 39 | 40 | StatCard( 41 | title: NSLocalizedString("reading_history.articles_read", comment: "Articles read"), 42 | value: formattedArticleCount, 43 | icon: "doc.text.fill" 44 | ) 45 | } 46 | 47 | if selectedTimeRange != .day { 48 | HStack { 49 | StatCard( 50 | title: NSLocalizedString("reading_history.daily_average", comment: "Daily average"), 51 | value: formattedAverageTime, 52 | icon: "chart.bar.fill" 53 | ) 54 | 55 | StatCard( 56 | title: NSLocalizedString("reading_history.average_per_article", comment: "Average per article"), 57 | value: formattedAveragePerArticle, 58 | icon: "book.fill" 59 | ) 60 | } 61 | } 62 | } 63 | .padding(.vertical, 8) 64 | } 65 | 66 | Section(NSLocalizedString("reading_history.records", comment: "Reading records")) { 67 | if records.isEmpty { 68 | Text(NSLocalizedString("reading_history.no_records", comment: "No reading records")) 69 | .foregroundColor(.secondary) 70 | .frame(maxWidth: .infinity, alignment: .center) 71 | .padding() 72 | } else { 73 | ForEach(records) { record in 74 | ReadingRecordRow(record: record) 75 | } 76 | } 77 | } 78 | } 79 | .navigationTitle(NSLocalizedString("reading_history.title", comment: "Reading history")) 80 | } 81 | 82 | private var records: [ReadingRecord] { 83 | let calendar = Calendar.current 84 | let now = Date() 85 | 86 | switch selectedTimeRange { 87 | case .day: 88 | let startOfDay = calendar.startOfDay(for: now) 89 | return historyManager.getRecords(from: startOfDay, to: now) 90 | 91 | case .week: 92 | guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now)) else { 93 | return [] 94 | } 95 | return historyManager.getRecords(from: weekStart, to: now) 96 | 97 | case .month: 98 | guard let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { 99 | return [] 100 | } 101 | return historyManager.getRecords(from: monthStart, to: now) 102 | } 103 | } 104 | 105 | private var formattedReadingTime: String { 106 | let minutes = records.reduce(0) { $0 + $1.duration / 60 } 107 | if minutes < 60 { 108 | let key = minutes == 1 ? "reading_history.minute" : "reading_history.minutes" 109 | return String(format: NSLocalizedString(key, comment: "Minutes"), Int(minutes)) 110 | } else { 111 | let hours = Int(minutes / 60) 112 | let remainingMinutes = Int(minutes.truncatingRemainder(dividingBy: 60)) 113 | return String(format: NSLocalizedString("reading_history.hours_minutes", comment: "Hours and minutes"), hours, remainingMinutes) 114 | } 115 | } 116 | 117 | private var articleCount: Int { 118 | records.count 119 | } 120 | 121 | private var formattedArticleCount: String { 122 | let count = records.count 123 | let key = count == 1 ? "reading_history.article_read" : "reading_history.articles_read" 124 | return String(format: NSLocalizedString(key, comment: "Articles read"), count) 125 | } 126 | 127 | private var formattedAverageTime: String { 128 | let totalMinutes = records.reduce(0) { $0 + $1.duration / 60 } 129 | 130 | // 计算天数 131 | let calendar = Calendar.current 132 | let now = Date() 133 | var startDate: Date 134 | 135 | switch selectedTimeRange { 136 | case .day: 137 | return "" // 不显示每日平均 138 | case .week: 139 | guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now)) else { 140 | let key = "reading_history.minute" 141 | return String(format: NSLocalizedString(key, comment: "Minutes"), 0) 142 | } 143 | startDate = weekStart 144 | case .month: 145 | guard let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { 146 | let key = "reading_history.minute" 147 | return String(format: NSLocalizedString(key, comment: "Minutes"), 0) 148 | } 149 | startDate = monthStart 150 | } 151 | 152 | guard let days = calendar.dateComponents([.day], from: startDate, to: now).day, 153 | days > 0 else { 154 | let key = "reading_history.minute" 155 | return String(format: NSLocalizedString(key, comment: "Minutes"), 0) 156 | } 157 | 158 | let averageMinutes = totalMinutes / Double(days) 159 | if averageMinutes < 60 { 160 | let key = averageMinutes == 1 ? "reading_history.minute" : "reading_history.minutes" 161 | return String(format: NSLocalizedString(key, comment: "Minutes"), Int(averageMinutes)) 162 | } else { 163 | let hours = Int(averageMinutes / 60) 164 | let remainingMinutes = Int(averageMinutes.truncatingRemainder(dividingBy: 60)) 165 | return String(format: NSLocalizedString("reading_history.hours_minutes", comment: "Hours and minutes"), hours, remainingMinutes) 166 | } 167 | } 168 | 169 | private var formattedAveragePerArticle: String { 170 | guard !records.isEmpty else { 171 | let key = "reading_history.minute" 172 | return String(format: NSLocalizedString(key, comment: "Minutes"), 0) 173 | } 174 | let averageMinutes = records.reduce(0) { $0 + $1.duration / 60 } / Double(records.count) 175 | if averageMinutes < 60 { 176 | let key = averageMinutes == 1 ? "reading_history.minute" : "reading_history.minutes" 177 | return String(format: NSLocalizedString(key, comment: "Minutes"), Int(averageMinutes)) 178 | } else { 179 | let hours = Int(averageMinutes / 60) 180 | let remainingMinutes = Int(averageMinutes.truncatingRemainder(dividingBy: 60)) 181 | return String(format: NSLocalizedString("reading_history.hours_minutes", comment: "Hours and minutes"), hours, remainingMinutes) 182 | } 183 | } 184 | } 185 | 186 | struct StatCard: View { 187 | let title: String 188 | let value: String 189 | let icon: String 190 | 191 | var body: some View { 192 | VStack(alignment: .leading, spacing: 8) { 193 | Label(title, systemImage: icon) 194 | .font(.caption) 195 | .foregroundColor(.secondary) 196 | 197 | Text(value) 198 | .font(.title2.bold()) 199 | .foregroundColor(.primary) 200 | } 201 | .frame(maxWidth: .infinity, alignment: .leading) 202 | .padding() 203 | .background(Color(uiColor: .secondarySystemGroupedBackground)) 204 | .cornerRadius(12) 205 | } 206 | } 207 | 208 | struct ReadingRecordRow: View { 209 | let record: ReadingRecord 210 | 211 | var body: some View { 212 | VStack(alignment: .leading, spacing: 8) { 213 | Text(record.articleTitle) 214 | .font(.headline) 215 | .lineLimit(1) 216 | 217 | HStack { 218 | let minutes = Int(record.duration / 60) 219 | let key = minutes == 1 ? "reading_history.minute" : "reading_history.minutes" 220 | Label(String(format: NSLocalizedString(key, comment: "Minutes"), minutes), systemImage: "clock") 221 | 222 | Spacer() 223 | 224 | Text(record.startTime.formatted(.relative(presentation: .named))) 225 | } 226 | .font(.caption) 227 | .foregroundColor(.secondary) 228 | } 229 | .padding(.vertical, 4) 230 | } 231 | } --------------------------------------------------------------------------------