diff --git a/lib/components/news.dart b/lib/components/news.dart index 38fbc70..3e1582f 100644 --- a/lib/components/news.dart +++ b/lib/components/news.dart @@ -11,9 +11,17 @@ import 'package:moyubie/controller/settings.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:http/http.dart' as http; +import 'package:timeago/timeago.dart' as timeago; import '../utils/ai_recommend.dart'; +class PromotedRecord { + DateTime at; + List records; + + PromotedRecord(this.at, this.records); +} + mixin BgTaskIndicatorExt on State { int? _max; int? _current; @@ -45,20 +53,20 @@ mixin BgTaskIndicatorExt on State { if (max != null) { this._max = max; } - if (taskName != null) { - this._task_text = taskName; - } + this._task_text = taskName ?? ""; }); return await fut((x) { - setState(() { - if (x < 0 || (_max != null && x > _max!)) { - this._current = null; - this._max = null; - return; - } - this._current = x; - }); + if (mounted) { + setState(() { + if (x < 0 || (_max != null && x > _max!)) { + this._current = null; + this._max = null; + return; + } + this._current = x; + }); + } }); } @@ -92,10 +100,29 @@ class News { } class NewsService { - List _cached = _TestData.hackerNews; + List _cached = []; + List _record = []; + + int lastTab = 0; final _concurrency = 8; + final _limit = 50; - Future refreshTopNews(int limit) async { + Future savePromoted(PromotedRecord rec) async { + _record.add(rec); + } + + Future> fetchPromoted() async { + final l = _record.toList(growable: false); + // DESC ORDER. + l.sort((a, b) => b.at.compareTo(a.at)); + return l; + } + + Future getUserTags() async { + return _TestData.prof; + } + + Future refreshTopNews() async { var topStories = 'https://hacker-news.firebaseio.com/v0/topstories.json'; var uri = Uri.parse(topStories); var response = await http.get( @@ -105,13 +132,10 @@ class NewsService { if (response.statusCode == 200) { var topStoriesId = json.decode(response.body) as List; var news = []; - var futs = []; + var futs = ListQueue(); for (var id in topStoriesId) { if (futs.length > _concurrency) { - final res = await Future.any( - futs.indexed.map((p) => p.$2.then((value) => (p.$1, value)))); - futs.removeAt(res.$1); - final newsJson = res.$2; + final newsJson = await futs.removeFirst(); if (newsJson == null || newsJson['url'] == null) { continue; } @@ -122,7 +146,7 @@ class NewsService { newsJson['title'], "${newsJson["score"]} scores by ${newsJson["by"]}")); } - if (news.length >= limit) { + if (news.length >= _limit) { if (futs.isEmpty) { break; } @@ -148,9 +172,9 @@ class NewsService { } } - Future> cachedOrFetchTopNews(int limit) async { - if (_cached.length < limit) { - await refreshTopNews(limit); + Future> cachedOrFetchTopNews() async { + if (_cached.length < _limit) { + await refreshTopNews(); } return _cached; } @@ -459,26 +483,26 @@ class _TestData { "LinkedIn采用了Google开源的协议缓冲区(Protocol Buffers),并将延迟降低了高达60%。这意味着用户可以更快地访问LinkedIn的服务。对于经常使用LinkedIn的用户来说,这是一个可能感兴趣的新闻。") ]; - static final prof = UserProfile(tags: ["旅游", "文学", "生活", "娱乐"]); + static final prof = UserProfile(tags: ["炼金术", "魔法", "函数式编程", "气候"]); - static List<_Promoted> getPromoted() { + static List getPromoted() { return simplePrompted.mapMany((e) { News? item = hackerNews.firstWhereOrNull((element) { return element.id == e.id; }); if (item == null) { - return <_Promoted>[]; + return []; } - return [_Promoted(item, e.reason)]; + return [Promoted(item, e.reason)]; }).toList(); } } -class _Promoted { +class Promoted { News news; String reason; - _Promoted(this.news, this.reason); + Promoted(this.news, this.reason); } class NewsWindow extends StatefulWidget { @@ -503,8 +527,12 @@ class _NewsWindowState extends State String? _openedLink; String? _err; List _news = []; - List<_Promoted> _promoted_news = []; - List<_Promoted> _all_promoted = []; + List _promoted_news = []; + + _NewsWindowState(); + + late NewsService _srv; + late List _tab_key; AIContext get _ai_ctx { final settings = Get.find(); @@ -522,23 +550,28 @@ class _NewsWindowState extends State @override void dispose() { + _tabctl.removeListener(onTabChanged); super.dispose(); } @override void initState() { + _tab_key = Iterable.generate(2, (i) => GlobalKey()).toList(); + _srv = Get.find(); _webctl?.setNavigationDelegate(NavigationDelegate(onProgress: (i) { setState(() { _web_load_progress = i; }); })); runOneShotTask(() async { - final news = await Get.find().cachedOrFetchTopNews(20); + final news = await _srv.cachedOrFetchTopNews(); setState(() { _news = news; }); }(), taskName: "Fetching hacker news for you..."); - _tabctl = TabController(length: 2, vsync: this); + _tabctl = TabController(initialIndex: _srv.lastTab, length: 2, vsync: this); + _tabctl.addListener(onTabChanged); + onTabChanged(force: true); super.initState(); } @@ -552,31 +585,38 @@ class _NewsWindowState extends State startPane: Scaffold( appBar: appbar(), body: TabBarView(controller: _tabctl, children: [ - EasyRefresh( - controller: _rfrctl, - header: refreshHeader, - onRefresh: refreshNews, - child: ListView( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - children: [ - ..._news.map((e) => - _NewsCard(e, onEnter: (news) => {setUrl(news.url)})) - ], - ), - ), - EasyRefresh( - controller: _rfrctl, - header: aiPromoteHeader, - onRefresh: promoteNews, - child: ListView( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - children: [ - ...(_search.text.isEmpty ? _all_promoted : _promoted_news) - .map((e) => _PromotedCard(e, - onEnter: (promoted) => {setUrl(promoted.news.url)})), - ], - ), - ), + bgTaskRunning + ? prog() + : EasyRefresh( + key: _tab_key[0], + controller: _rfrctl, + header: refreshHeader, + onRefresh: refreshNews, + child: ListView( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + children: [ + ..._news.map((e) => + _NewsCard(e, onEnter: (news) => {setUrl(news.url)})) + ], + ), + ), + bgTaskRunning + ? prog() + : EasyRefresh( + key: _tab_key[1], + controller: _rfrctl, + header: aiPromoteHeader, + onRefresh: promoteNews, + child: ListView( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + children: [ + ..._promoted_news.map((e) => _PromotedGroup( + record: e, + onEnter: (promoted) => + {setUrl(promoted.news.url)})), + ], + ), + ), ])), endPane: contentForWeb(), panePriority: panePriority, @@ -674,13 +714,6 @@ class _NewsWindowState extends State } AppBar appbar() { - final actions = [ - IconButton( - onPressed: () { - refreshNews(); - }, - icon: Icon(Icons.refresh)) - ]; var bottom = TabBar( tabs: const [ Tab(icon: Icon(Icons.newspaper)), @@ -693,7 +726,6 @@ class _NewsWindowState extends State toolbarHeight: 40, foregroundColor: Colors.white, backgroundColor: Color.fromARGB(255, 70, 70, 70), - actions: actions, bottom: bottom); } @@ -740,33 +772,46 @@ class _NewsWindowState extends State } } + Future fetchPromotedNews() async { + final promoted = await _srv.fetchPromoted(); + if (mounted && _tabctl.index == 1) { + setState(() { + _promoted_news = promoted; + }); + } + } + Future promoteNews() async { - final promotedList = await NewsPromoter(_ai_ctx).promotNews( - _TestData.prof, + final currentPromoted = (await _srv.fetchPromoted()) + .mapMany((e) => e.records.map((e) => e.news.id)) + .toSet(); + final promotedList = await NewsPromoter(_ai_ctx).promoteNews( + await _srv.getUserTags(), _news + .where((element) => !currentPromoted.contains(element.id)) .map((e) => e.convertToJsonForRecommend()) .toList(growable: false)); - var promotedFull = <_Promoted>[]; - var newNews = _news.where((element) { + var promotedFull = _news.mapMany((element) { var recommend = promotedList.firstWhereOrNull((rec) => rec.id == element.id); if (recommend != null) { - promotedFull.add(_Promoted(element, recommend.reason)); + return [Promoted(element, recommend.reason)]; } - return recommend == null; + return []; }).toList(growable: false); - setState(() { - _all_promoted = [...promotedFull, ..._all_promoted]; - _promoted_news = promotedFull; - _news = newNews; - }); + final promoted = PromotedRecord(DateTime.now(), promotedFull); + await _srv.savePromoted(promoted); + if (mounted) { + setState(() { + _promoted_news = [promoted, ..._promoted_news]; + }); + } } Future refreshNews() async { - final srv = Get.find(); var topNews = await runOneShotTask(() async { - await srv.refreshTopNews(20); - return srv._cached; + await _srv.refreshTopNews(); + return _srv._cached; }(), taskName: "Refreshing the top news..."); var newNews = topNews.where((news) => news.title.contains(_search.text)).toList(); @@ -777,6 +822,29 @@ class _NewsWindowState extends State }); } } + + void onTabChanged({bool force = false}) async { + if (!_tabctl.indexIsChanging && !force) { + return; + } + _srv.lastTab = _tabctl.index; + if (_tabctl.index == 0) { + runOneShotTask(() async { + final news = await _srv.cachedOrFetchTopNews(); + if (mounted && _tabctl.index == 0) { + setState(() { + _news = news; + }); + } + }()); + return; + } + if (_tabctl.index == 1) { + runOneShotTask(fetchPromotedNews()); + return; + } + throw Exception("unexpected tab index ${_tabctl.index}"); + } } class _NewsCard extends StatelessWidget { @@ -803,10 +871,40 @@ class _NewsCard extends StatelessWidget { } } +class _PromotedGroup extends StatelessWidget { + final PromotedRecord record; + final void Function(Promoted)? onEnter; + + const _PromotedGroup({required this.record, this.onEnter}); + + @override + Widget build(BuildContext context) { + final ts = record.at; + return Column(children: [ + Container( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + Text( + timeago.format(ts).capitalizeFirst!, + ), + Expanded( + child: Divider( + indent: 8, + height: 8, + color: Theme.of(context).primaryColorLight, + )), + ], + )), + ...record.records.map((e) => _PromotedCard(e, onEnter: onEnter)) + ]); + } +} + class _PromotedCard extends StatelessWidget { final Key? key; - final _Promoted _promoted; - final void Function(_Promoted)? onEnter; + final Promoted _promoted; + final void Function(Promoted)? onEnter; const _PromotedCard(this._promoted, {this.onEnter, this.key}); diff --git a/lib/utils/ai_recommend.dart b/lib/utils/ai_recommend.dart index e4fd29d..fad3c8f 100644 --- a/lib/utils/ai_recommend.dart +++ b/lib/utils/ai_recommend.dart @@ -158,11 +158,12 @@ class NewsPromoter extends WithOpenAI { static const _sys_prompt = '你是一个新闻推荐服务。你通过且仅通过 `get_user_info` 获得用户分析报告,通过 `get_news` 获得今日新闻。' '你要从中选出尽可能多用户可能感兴趣的新闻(但是不要超过五条),并给出相应的理由,随后将理由翻译给用户的惯用语言,推荐理由请不要和标题过度相似。' - '传递推荐给 `recommend_news`,你只需要传递 id 和推荐理由,不要带上新闻的其它属性。不要使用 `get_user_info` 以外任何地方的用户信息,也不要用和用户信息不相符的理由推荐。'; + '传递推荐给 `recommend_news`,你只需要传递 id 和推荐理由,不要带上新闻的其它属性。不要使用 `get_user_info` 以外任何地方的用户信息。' + '不要用和用户信息不相符的理由推荐,也不要推荐用户感兴趣主题以外的新闻。'; NewsPromoter(super._context); - Future> promotNews( + Future> promoteNews( UserProfile userInfo, List> news) async { final res = await OpenAI.instance.chat.create( model: _context.model, diff --git a/pubspec.lock b/pubspec.lock index 3867d45..b7d5d31 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -464,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.17" + intl: + dependency: transitive + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" io: dependency: transitive description: @@ -925,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: "4addcda362e51f23cf7ae2357fccd053f29d59b4ddd17fb07fc3e7febb47a456" + url: "https://pub.dev" + source: hosted + version: "3.5.0" tuple: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5122c3a..5258f60 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: url_launcher: ^6.1.12 easy_refresh: ^3.3.2+1 mysql_client: ^0.0.27 + timeago: ^3.5.0 dev_dependencies: flutter_test: diff --git a/test/ai_playground_test.dart b/test/ai_playground_test.dart index c47da3b..2f31631 100644 --- a/test/ai_playground_test.dart +++ b/test/ai_playground_test.dart @@ -17,7 +17,7 @@ void main() { test("Promote Test!",() async { var ctx = AIContext(api_key: Platform.environment["OAI_API"]!, model: "gpt-3.5-turbo"); var rec = NewsPromoter(ctx); - var res = await rec.promotNews(UserProfile(tags: ["编程", "科技", "软件开发"]), + var res = await rec.promoteNews(UserProfile(tags: ["编程", "科技", "软件开发"]), [{"title": "最新的 Rust 库,可以帮助大家再也没有编译错误!", "id": 1}, {"title": "国家今日决定免除一切个人所得税征收。", "id": 2}, {"title": "在山麓的湖泊中发现水怪!", "id": 3},