diff --git a/design-qa.md b/design-qa.md index de1758a0..b8847849 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,94 +1,56 @@ -# Android Design QA +# Dream Report Design QA -## Sources +**Source visual truth path** -- Open Design HTML authority: `newtab.html`, `browsing.html`, `settings.html`, `history.html`, `bookmarks.html`, `downloads.html`, and `extensions.html`. -- Tokens and behavior: `DESIGN-TOKENS.md`, `HANDOFF.md`, and `ACCEPTANCE.md`. -- Exported visual reference: `image.png`. -- Desktop brand authority: the borderless `branding/dao_logo.png` source artwork. -- Runtime captures: API 34 Pixel 6 emulator at 1080 x 2400 in light and dark themes. +`/Users/moonrailgun/Library/Application Support/Open Design/namespaces/release-stable/data/projects/d7969910-805f-49b3-9d34-80d6d21d2420/dream-recap-redesign.html` -## Comparison +**Implementation screenshot path** -The exported new-tab reference and the emulator capture were normalized to the same canvas and inspected side by side. The Android implementation keeps the neutral palette, typography hierarchy, mark, greeting, pill search field, spacing rhythm, and icon treatment. Android system bars replace the prototype device frame as required. +Unavailable. The required in-app browser is not available in this session, and the project cannot produce a fresh Dao binary because the shared generated Chromium checkout fails normal import on 15 unrelated Settings patches before compilation begins. -The exported reference contains an overlap between the search pill and greeting. The implementation follows the newer acceptance requirement instead: the greeting and search field have clear separation at the target viewport. +**Viewport and normalization** -The borderless desktop artwork was rendered beside the API 34 launcher and new-tab captures. The Android adaptive icon keeps the complete ink-circle Dao mark inside the system safe zone, and the new-tab page uses the transparent original without cropping, a macOS frame, or a replacement text glyph. +- Intended comparison viewport: 1440 x 1000 CSS pixels. +- Source: live responsive HTML; no fixed raster pixel dimensions or device scale factor. +- Implementation: no browser-rendered pixels available, so density normalization could not be performed. +- State: history route with the latest completed daily report selected. -## Findings and fixes +**Full-view comparison evidence** -- P0: none. -- P1 fixed: new-tab greeting/search overlap. -- P1 fixed: focusing the editable text field now activates the animated search state. -- P1 fixed: browsing drawer order now matches the acceptance list exactly. -- P1 fixed: dark-theme status and navigation bar icons now use a readable light appearance after launch and focus changes. -- P2 fixed: Material 3 download progress stop indicator removed to match the simple prototype bar. -- P2 fixed: localized resource collision for the active-download section label removed. -- P2 fixed: placeholder `N` tile and macOS-specific framed icon replaced by the canonical borderless Dao artwork in both launcher and in-app surfaces. -- P2 fixed: scanner entry uses the requested Lucide `ScanLine` glyph instead of the generic `Scan` glyph. +Blocked. The source artifact was inspected directly as HTML/CSS/JS and its selected layout tokens were mapped into `dao_dream_app.ts`, but a source screenshot and browser-rendered implementation screenshot could not be placed into a same-viewport comparison. -## Interaction checks +**Focused region comparison evidence** -- Search field docks to the top and exposes recent or filtered suggestions. -- Query matching is case-insensitive, limited to five results, and highlights the matching text. -- Scanner overlay opens, animates, and closes. -- Address submission loads a real GeckoView page. -- Right drawer opens from the address bar and closes through the scrim or back action. -- History search, bookmark segment, download pause, and extension switches update locally. -- Dark mode updates immediately and persists through Preferences DataStore across force-stop and relaunch. -- Launcher and new-tab branding remain legible at their rendered sizes without clipping the ink-circle mark. +Blocked for the same reason. The regions requiring focused comparison are the annual activity heatmap, compact history rows, report header, TL;DR card, rhythm slots, theme cards, statistic strip, memory candidates, and full-report disclosure. -## Tab-count refinement — 2026-08-04 +**Primary interactions checked** -- Source visual truth: `/var/folders/0l/4dc990md3yn_g3b46dtmhp880000gn/T/orca-paste-1785776258694-95491411-83d3-4a0b-a16d-d41a4793d627.png` plus the explicit instruction to remove the count from expanded search and use a tighter Chrome-style frame elsewhere. -- Implementation captures: `/tmp/dao-tab-count-home.png`, `/tmp/dao-tab-count-search.png`, and `/tmp/dao-tab-count-browser.png`. -- Full-view comparison: `/tmp/dao-tab-count-comparison.png`. -- Focused comparison: `/tmp/dao-tab-count-focused-comparison.png`; left shows the reported oversized search-state control, center shows the count removed from expanded search, and right shows the compact browsing-state frame. -- Viewport: Pixel 6 API 34 emulator, 1080 × 2400 physical pixels. The 848 × 1788 framed source was cropped to its 761 × 1687 app screen; the implementation was normalized to 761 × 1687 for the full-view comparison. Native Compose density was preserved in the runtime captures. -- State: light theme, expanded search with `http://www.google.cn/m`; idle new tab; browsing the submitted URL. +- The complete WebUI suite passes: 61 test files and 728 tests, including daily and weekly report loading, shared history selection, 53-week heatmap rendering, structured and legacy recaps, measured rhythm buckets, rerun, copy-image states, confirmation-gated habit persistence, non-destructive rejection, source-domain exclusion, debug metadata, loading, empty, and error states. +- Browser interaction and console-error checks are unavailable without a browser-rendered fresh build. -### Findings and comparison history +**Findings** -- P1 fixed: the tab-count control no longer appears in expanded search, leaving only clear and exit actions. -- P2 fixed: the visible frame no longer fills its 32/36 dp touch target. It is a centered 24 dp rounded square with a 2 dp foreground border, while the larger accessible hit area remains intact. -- Post-fix evidence: the focused comparison shows no count between the two search actions and a compact framed count in the browsing address bar. No actionable P0/P1/P2 differences remain. +- [P1] Browser-rendered visual fidelity is unverified. + - Location: complete `dao://dream` history route. + - Evidence: no implementation screenshot exists for same-viewport comparison. + - Impact: typography, final computed spacing, dark mode, and Chromium WebUI rendering cannot be accepted visually from source and jsdom tests alone. + - Fix: restore the shared Chromium import baseline, run `npm run rebuild`, launch the debug app, capture `dao://dream/` at 1440 x 1000, and compare it against a same-size capture of the Open Design artifact. -### Required fidelity surfaces +**Implementation checklist** -- Fonts and typography: the existing system font, semibold weight, and centered numeric label remain consistent; the number was reduced to 11 sp to fit the tighter frame without crowding. -- Spacing and layout rhythm: the visible frame is close to the numeral while the original touch-target spacing is preserved; removing the search-state frame and divider gives the editing actions an even rhythm. -- Colors and visual tokens: the frame and numeral use the existing Nova foreground token for Chrome-like contrast in both themes. -- Image quality and asset fidelity: no raster or icon assets were added or replaced; the control remains native Compose UI. -- Copy and content: no user-visible strings changed, and the live `BrowserStore` count remains the displayed value outside expanded search. +- [x] Match the Open Design two-column desktop structure and responsive stack. +- [x] Use real report history for activity cells and report selection. +- [x] Add structured summary, rhythm, themes, statistics, memory candidates, and folded markdown. +- [x] Preserve rerun, share, exclusions, debug, loading, empty, and error behavior. +- [x] Support legacy markdown-only reports. +- [ ] Complete browser-rendered same-viewport visual comparison and console check. -final result: passed +**Comparison history** -## Search cursor alignment and empty close behavior — 2026-08-04 +- Iteration 1: source implementation review completed from the Open Design artifact; all 728 WebUI tests passed; visual comparison blocked before the first screenshot pair because no fresh implementation could be rendered. -- Source visual truth: `/var/folders/0l/4dc990md3yn_g3b46dtmhp880000gn/T/orca-paste-1785825111072-1e131078-c517-42cf-811f-4f06c420b9da.png`. -- Implementation screenshot: `/tmp/dao-search-cursor-final.png`. -- Side-by-side comparison: `/tmp/dao-search-reference-vs-final.png`; source is on the left and the revised emulator capture is on the right. -- Viewport: Pixel 6 API 34 emulator at 1080 × 2400 physical pixels and 420 dpi. The 940 × 220 implementation crop was normalized to the 744 × 174 source pixels for comparison. -- State: light theme, empty expanded search, focused real input, visible cursor and clear action. -- Full-view evidence: `/tmp/dao-search-alignment-after.png` confirms the expanded search layout, suggestions region, keyboard, and safe-area placement. -- Focused evidence: `/tmp/dao-search-reference-vs-final.png` confirms the cursor and placeholder share the same centered 16 sp text metrics. A focused region was required because the reported defect was not legible at full-view scale. +**Follow-up polish** -### Findings and comparison history +- Reassess small-screen statistic-label wrapping and 53-week heatmap scroll position after a real browser capture. -- P2 fixed: the placeholder and editable text previously used separate unconstrained decoration children, which made the focused cursor appear vertically disconnected from the placeholder. Both now share one center-start container and the same 16 sp font size and line height. -- P1 fixed: the clear action previously remained a no-op when the expanded query was already empty. It now exits expanded search and restores the homepage; address editing still uses its supplied exit callback. -- First iteration: a shared 20 sp line height aligned the layout structure but made the cursor visibly taller than the reference. -- Final iteration: reducing the shared line height to 16 sp matched the visible cursor/text height while keeping their baseline and origin unified. No actionable P0/P1/P2 differences remain. - -### Required fidelity surfaces - -- Fonts and typography: system font, 16 sp size, 16 sp line height, weight, and placeholder color are shared between the editable text and placeholder. -- Spacing and layout rhythm: search icon, text origin, clear action, 56 dp pill height, border, and outer padding remain unchanged; only the internal text layout was unified. -- Colors and visual tokens: foreground, faint placeholder, border, and surface continue using the existing Nova theme tokens. -- Image quality and asset fidelity: no image or icon assets changed; the existing Lucide search and close icons remain intact. -- Copy and content: localized placeholder and accessibility labels are unchanged. -- Primary interactions: text entry, non-empty clear, empty close-to-home, keyboard focus, and homepage tab-count restoration were exercised on the emulator. -- Runtime errors: no app fatal exception was observed during the capture and interaction pass. - -final result: passed +final result: blocked diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 7d2cb5fb..c0845cc2 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -123,7 +123,7 @@ Flagship feature. C++ services + `dao://dao-agent` WebUI + vendor runtime. | ☐ | Agent web tools search/fetch tiering | `src/dao/.../resources/agent/web_search/`, `dao_settings_view.ts`, `dao_agent_ui.cc` | — | Provider built-in search is preferred; Auto mode uses configured Jina Search before DuckDuckGo HTML; DuckDuckGo anomaly/verification pages report that accurately instead of `HTML structure changed`; `fetch_url` still falls back from Jina Reader to browser fetch | | ☐ | Dream scheduler and Agent settings controls | `src/dao/.../agent/dao_dream_service.*`, `dao_settings_view.ts`, `dream_bridge.ts` | — | Dream remains off by default; enabling requires memory; nightly/catch-up/manual runs honor idle/time/date gates and show status/history in settings | | ☐ | Dream material privacy and excluded-domain filtering | `src/dao/.../agent/dao_dream_material_collector.*`, `dao_dream_domain_utils.*`, `dao_pref_names.*` | — | Excluded domains are normalized and removed before titles/search queries/debug material leave C++; stats do not leak excluded domain names | -| ☐ | Dream report page, rerun, sharing, and habit feedback | `dao_dream_app.ts`, `dao_dream_runner.ts`, `dao_share_image.ts`, `dao_agent_ui.cc` | — | `dao://dream` loads today/history; rerun by date replaces only on success; failed rerun preserves existing report; share image, debug view, confirm/reject habit actions work | +| ☐ | Dream one-minute recap, history, rerun, sharing, and habit feedback | `dao_dream_app.ts`, `dao_dream_runner.ts`, `dao_dream_service.cc`, `dao_share_image.ts`, `dao_agent_ui.cc` | — | `dao://dream` loads up to 371 daily reports for the 53-week activity heatmap plus 53 weekly reports for the shared 14-item history rail; structured summaries, measured per-period foreground rhythm (with legacy model-estimate fallback), uncapped aggregate counts, themes, stats, and memory candidates render; candidates affect memory only after confirmation and rejection preserves existing memory; legacy markdown-only reports derive useful recap content; daily/weekly selection, rerun replacement/failure preservation, share image, full-report disclosure, domain exclusion, debug view, and feedback actions work | ## 4. Picture-in-Picture Enhancements diff --git a/docs/features.md b/docs/features.md index 7d9e3bfb..ceedb4e5 100644 --- a/docs/features.md +++ b/docs/features.md @@ -195,15 +195,28 @@ The stack includes: **LLM tool calling**, **long-term memory** (SQLite + FTS5), - **DreamMaterialCollector** — aggregates one day of signals: history (domain+title+time-bucket granularity, top 50; full URLs never leave the browser), search keywords (extracted in C++), agent conversation - excerpts, proactive-feedback stats. + excerpts, proactive-feedback stats. Visit-count buckets remain available to + the model, while measured foreground seconds are also aggregated by morning, + afternoon, evening, and night for the report rhythm visualization. - **dao_dream_runner.ts** — resident agent WebUI executes the LLM summarization (user's configured provider) and returns structured - habits + a morning-report markdown. -- Results: habits merged into `preferences` (LLM confidence capped at - 0.8; user confirmation raises to 0.95), report archived in the - `dream_reports` table, morning report card in the Agent panel with - per-habit confirm/reject and an optional debug view of the exact LLM - input (`dao.dream_debug`). + one-minute recap data (summary, time-of-day rhythm, and up to three themes), + habits, and a full morning-report markdown. The recap is persisted inside + the report's existing material-stats JSON, so older databases need no + migration. +- Results: memory suggestions remain report-local candidates until the user + confirms one at confidence 0.95; rejection never deletes or rewrites an + existing preference. The report is archived in the `dream_reports` table, + with a morning report card in the Agent panel, per-candidate confirm/reject, + and an optional debug view of the exact LLM input (`dao.dream_debug`). +- **`dao://dream` one-minute recap** — responsive two-column report with a + 53-week real-report activity heatmap, compact daily-and-weekly history rail, + summary card, + measured foreground-focus rhythm, topic cards, aggregate counts, + memory-candidate actions, and a folded full report. Existing rerun, image + sharing, source-domain exclusion, + and debug controls remain available. Legacy markdown-only reports derive a + summary and topic cards locally instead of rendering an incomplete page. ## 3. Picture-in-Picture Enhancements diff --git a/src/dao/browser/agent/dao_agent_memory_service.cc b/src/dao/browser/agent/dao_agent_memory_service.cc index 74f5e3fe..f87df3b7 100644 --- a/src/dao/browser/agent/dao_agent_memory_service.cc +++ b/src/dao/browser/agent/dao_agent_memory_service.cc @@ -114,6 +114,23 @@ void DaoAgentMemoryService::LoadConversationMessagesInRange( std::move(callback)); } +void DaoAgentMemoryService::CountUserConversationSessionsInRange( + base::Time start, + base::Time end, + base::OnceCallback callback) { + DCHECK_CALLED_ON_VALID_SEQUENCE(ui_sequence_checker_); + background_task_runner_->PostTaskAndReplyWithResult( + FROM_HERE, + base::BindOnce( + [](DaoAgentMemoryStore* store, base::Time start_time, + base::Time end_time) { + return store->CountUserConversationSessionsInRange(start_time, + end_time); + }, + store_.get(), start, end), + std::move(callback)); +} + // --- Conversation Summaries --- void DaoAgentMemoryService::SaveConversationSummary( diff --git a/src/dao/browser/agent/dao_agent_memory_service.h b/src/dao/browser/agent/dao_agent_memory_service.h index f427ce14..e32b3ccd 100644 --- a/src/dao/browser/agent/dao_agent_memory_service.h +++ b/src/dao/browser/agent/dao_agent_memory_service.h @@ -57,6 +57,10 @@ class DaoAgentMemoryService : public KeyedService { base::Time end, int limit, base::OnceCallback)> callback); + void CountUserConversationSessionsInRange( + base::Time start, + base::Time end, + base::OnceCallback callback); // Conversation summaries void SaveConversationSummary( diff --git a/src/dao/browser/agent/dao_agent_memory_store.cc b/src/dao/browser/agent/dao_agent_memory_store.cc index 8747dc62..26d301d0 100644 --- a/src/dao/browser/agent/dao_agent_memory_store.cc +++ b/src/dao/browser/agent/dao_agent_memory_store.cc @@ -905,6 +905,19 @@ std::vector DaoAgentMemoryStore::LoadRecentMessages( return result; } +int DaoAgentMemoryStore::CountUserConversationSessionsInRange( + base::Time start, + base::Time end) { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); + sql::Statement stmt(db_->GetCachedStatement( + SQL_FROM_HERE, + "SELECT COUNT(DISTINCT session_id) FROM conversations " + "WHERE role='user' AND timestamp>=? AND timestamp DaoAgentMemoryStore::LoadConversationMessagesInRange(base::Time start, base::Time end, @@ -1708,7 +1721,7 @@ std::optional DaoAgentMemoryStore::GetDreamReportByDate( std::vector DaoAgentMemoryStore::GetDreamReports(int limit) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); - limit = std::clamp(limit, 1, 100); + limit = std::clamp(limit, 1, 371); sql::Statement stmt(db_->GetUniqueStatement( base::StrCat({"SELECT ", kDreamReportColumns, " FROM dream_reports WHERE status = 'completed' " diff --git a/src/dao/browser/agent/dao_agent_memory_store.h b/src/dao/browser/agent/dao_agent_memory_store.h index c5095b93..8ad3eeab 100644 --- a/src/dao/browser/agent/dao_agent_memory_store.h +++ b/src/dao/browser/agent/dao_agent_memory_store.h @@ -48,6 +48,7 @@ class DaoAgentMemoryStore { base::Time start, base::Time end, int limit); + int CountUserConversationSessionsInRange(base::Time start, base::Time end); // Conversation summaries bool SaveConversationSummary(const ConversationSummary& summary); diff --git a/src/dao/browser/agent/dao_agent_memory_store_unittest.cc b/src/dao/browser/agent/dao_agent_memory_store_unittest.cc index 21b5d143..93f29df5 100644 --- a/src/dao/browser/agent/dao_agent_memory_store_unittest.cc +++ b/src/dao/browser/agent/dao_agent_memory_store_unittest.cc @@ -128,6 +128,26 @@ TEST_F(DaoAgentMemoryStoreTest, ReadOnlySqlAppliesRowLimit) { EXPECT_TRUE(result.truncated); } +TEST_F(DaoAgentMemoryStoreTest, CountsUserConversationSessionsInRange) { + const base::Time now = base::Time::Now(); + auto save_message = [&](const std::string& session_id, const std::string& role, + base::Time timestamp) { + ConversationMessage message; + message.role = role; + message.content = "content"; + message.timestamp = timestamp; + return store_->SaveConversationMessages(session_id, {message}); + }; + ASSERT_TRUE(save_message("inside-a", "user", now - base::Hours(2))); + ASSERT_TRUE(save_message("inside-b", "user", now - base::Hours(1))); + ASSERT_TRUE(save_message("assistant-only", "assistant", + now - base::Minutes(30))); + ASSERT_TRUE(save_message("outside", "user", now - base::Days(2))); + + EXPECT_EQ(2, store_->CountUserConversationSessionsInRange( + now - base::Hours(6), now)); +} + TEST_F(DaoAgentMemoryStoreTest, CooldownTreatsRecentNotNowAsTemporaryBlock) { const base::Time now = base::Time::Now(); ASSERT_TRUE(store_->RecordActionFeedback( @@ -221,6 +241,22 @@ TEST_F(DaoAgentMemoryStoreTest, SearchEpisodesSkipsNegativeActionResults) { EXPECT_EQ("Useful search", episodes[0].title); } +TEST_F(DaoAgentMemoryStoreTest, DreamReportHistorySupportsAnnualActivity) { + for (int day = 0; day < 375; ++day) { + DreamReport report; + report.dream_date = std::to_string(day); + report.report_markdown = "report"; + report.habit_candidates = "[]"; + report.material_stats = "{}"; + report.status = "completed"; + ASSERT_TRUE(store_->SaveDreamReport(report)); + } + + const std::vector reports = store_->GetDreamReports(371); + + EXPECT_EQ(371u, reports.size()); +} + TEST_F(DaoAgentMemoryStoreTest, SearchEpisodesMatchesSavedSubdomains) { Episode episode = MakeEpisode("Docs workflow", "completed", "helpful"); episode.domain = "docs.google.com"; diff --git a/src/dao/browser/agent/dao_dream_browsertest.cc b/src/dao/browser/agent/dao_dream_browsertest.cc index effa54ce..ab86a08c 100644 --- a/src/dao/browser/agent/dao_dream_browsertest.cc +++ b/src/dao/browser/agent/dao_dream_browsertest.cc @@ -640,6 +640,68 @@ IN_PROC_BROWSER_TEST_F(DaoDreamBrowserTest, EXPECT_EQ("light", *quick_level); } +IN_PROC_BROWSER_TEST_F(DaoDreamBrowserTest, + CollectorStatsCountSignalsBeforeMaterialCaps) { + history::HistoryService* history = HistoryServiceFactory::GetForProfile( + browser()->profile(), ServiceAccessType::EXPLICIT_ACCESS); + ASSERT_TRUE(history); + DaoAgentMemoryService* memory = + DaoAgentMemoryServiceFactory::GetForProfile(browser()->profile()); + ASSERT_TRUE(memory); + const base::Time now = base::Time::Now(); + + for (int i = 0; i < 55; ++i) { + const GURL url(base::StringPrintf("https://daily-%02d.example/page", i)); + history->AddPage(url, now - base::Minutes(120 + i), + history::SOURCE_BROWSED); + } + for (int i = 0; i < 35; ++i) { + const GURL url(base::StringPrintf( + "https://www.google.com/search?q=query-%02d", i)); + history->AddPage(url, now - base::Minutes(30 + i), + history::SOURCE_BROWSED); + } + for (int i = 0; i < 12; ++i) { + ConversationMessage message; + message.role = "user"; + message.content = base::StringPrintf("Daily question %02d?", i); + message.timestamp = now - base::Minutes(10 + i); + std::vector messages; + messages.push_back(std::move(message)); + ASSERT_TRUE(SaveConversationMessagesForTest( + memory, base::StringPrintf("daily-cap-session-%02d", i), + std::move(messages))); + } + + DreamMaterialCollector collector(browser()->profile(), memory); + base::DictValue pack; + base::RunLoop loop; + collector.Collect(now - base::Hours(6), now, + base::BindLambdaForTesting([&](base::DictValue value) { + pack = std::move(value); + loop.Quit(); + })); + loop.Run(); + + const base::ListValue* domains = pack.FindList("history"); + const base::ListValue* queries = pack.FindList("search_queries"); + const base::ListValue* conversations = pack.FindList("conversations"); + ASSERT_TRUE(domains); + ASSERT_TRUE(queries); + ASSERT_TRUE(conversations); + EXPECT_EQ(DreamMaterialCollector::kMaxDomains, + static_cast(domains->size())); + EXPECT_EQ(DreamMaterialCollector::kMaxSearchQueries, + static_cast(queries->size())); + EXPECT_EQ(DreamMaterialCollector::kMaxConversationSessions, + static_cast(conversations->size())); + const base::DictValue* stats = pack.FindDict("stats"); + ASSERT_TRUE(stats); + EXPECT_EQ(56, stats->FindInt("history_domains").value_or(-1)); + EXPECT_EQ(35, stats->FindInt("search_queries").value_or(-1)); + EXPECT_EQ(12, stats->FindInt("conversation_sessions").value_or(-1)); +} + IN_PROC_BROWSER_TEST_F(DaoDreamBrowserTest, CollectorCapsLongHistoryText) { history::HistoryService* history = HistoryServiceFactory::GetForProfile( browser()->profile(), ServiceAccessType::EXPLICIT_ACCESS); @@ -4459,6 +4521,21 @@ IN_PROC_BROWSER_TEST_F(DaoDreamBrowserTest, // Complete the dream via the result path and verify persistence. base::DictValue result; result.Set("report_markdown", "# nightly report"); + base::ListValue habits; + base::DictValue habit; + habit.Set("key", "daily.must_wait_for_confirmation"); + habit.Set("value", "Daily candidates remain pending until confirmed."); + habit.Set("confidence", 0.8); + habit.Set("relation", "new"); + habits.Append(std::move(habit)); + result.Set("habits", std::move(habits)); + base::DictValue recap; + recap.Set("summary", "A focused day."); + base::DictValue time_buckets; + time_buckets.Set("morning_minutes", 12); + recap.Set("time_buckets", std::move(time_buckets)); + recap.Set("themes", base::ListValue()); + result.Set("recap", std::move(recap)); service->OnDreamResult(runner.last_request().request_id, std::move(result)); DaoAgentMemoryService* memory = @@ -4478,10 +4555,32 @@ IN_PROC_BROWSER_TEST_F(DaoDreamBrowserTest, EXPECT_EQ("completed", r->status); EXPECT_EQ("manual", r->trigger_kind); EXPECT_EQ("# nightly report", r->report_markdown); + std::optional material_stats = + base::JSONReader::Read(r->material_stats); + ASSERT_TRUE(material_stats.has_value()); + ASSERT_TRUE(material_stats->is_dict()); + const base::DictValue* stored_recap = + material_stats->GetDict().FindDict("recap"); + ASSERT_TRUE(stored_recap); + EXPECT_EQ("A focused day.", *stored_recap->FindString("summary")); verify_loop.Quit(); })); verify_loop.Run(); + std::vector preferences; + base::RunLoop preferences_loop; + memory->GetPreferences( + 100, 0.0, + base::BindLambdaForTesting([&](std::vector values) { + preferences = std::move(values); + preferences_loop.Quit(); + })); + preferences_loop.Run(); + EXPECT_TRUE(std::none_of( + preferences.begin(), preferences.end(), [](const Preference& pref) { + return pref.key == "daily.must_wait_for_confirmation"; + })); + service->ClearRunner(&runner); } diff --git a/src/dao/browser/agent/dao_dream_material_collector.cc b/src/dao/browser/agent/dao_dream_material_collector.cc index bcfd25b7..9a888529 100644 --- a/src/dao/browser/agent/dao_dream_material_collector.cc +++ b/src/dao/browser/agent/dao_dream_material_collector.cc @@ -74,6 +74,7 @@ struct DomainAgg { int total_seconds = 0; std::vector titles; std::map buckets; + std::map foreground_seconds_by_bucket; }; int MaterialSeconds(base::TimeDelta duration) { @@ -186,13 +187,21 @@ void DreamMaterialCollector::Collect(base::Time window_start, conversations_part_.clear(); preferences_part_.clear(); feedback_part_.clear(); + foreground_seconds_by_bucket_part_.clear(); + foreground_seconds_by_bucket_part_.Set("morning", 0); + foreground_seconds_by_bucket_part_.Set("afternoon", 0); + foreground_seconds_by_bucket_part_.Set("evening", 0); + foreground_seconds_by_bucket_part_.Set("night", 0); excluded_domains_ = LoadDreamExcludedDomains(profile_); + history_domain_count_ = 0; + search_query_count_ = 0; + conversation_session_count_ = 0; excluded_history_visits_ = 0; - // 4 parts: history (+search, same query), conversations, preferences, - // feedback. + // 5 parts: history (+search, same query), conversation excerpts, exact + // conversation-session count, preferences, and feedback. barrier_ = base::BarrierClosure( - 4, base::BindOnce(&DreamMaterialCollector::OnPartDone, + 5, base::BindOnce(&DreamMaterialCollector::OnPartDone, weak_factory_.GetWeakPtr())); // Part 1: history → domains + search queries. @@ -216,6 +225,12 @@ void DreamMaterialCollector::Collect(base::Time window_start, return; } std::map by_domain; + std::map foreground_seconds_by_bucket = { + {"morning", 0}, + {"afternoon", 0}, + {"evening", 0}, + {"night", 0}, + }; std::vector queries; std::set seen_queries; for (const auto& visit : visits) { @@ -240,10 +255,15 @@ void DreamMaterialCollector::Collect(base::Time window_start, agg.visit_count++; const base::TimeDelta foreground = ForegroundDurationFor(visit); - agg.foreground_seconds += MaterialSeconds(foreground); + const int foreground_seconds = MaterialSeconds(foreground); + agg.foreground_seconds += foreground_seconds; agg.total_seconds += MaterialSeconds(TotalDurationFor(visit, foreground)); - agg.buckets[BucketFor(visit.visit_row.visit_time)]++; + const std::string bucket = + BucketFor(visit.visit_row.visit_time); + agg.buckets[bucket]++; + agg.foreground_seconds_by_bucket[bucket] += foreground_seconds; + foreground_seconds_by_bucket[bucket] += foreground_seconds; const std::string title = TruncateMaterialText( base::UTF16ToUTF8(visit.url_row.title())); @@ -256,6 +276,8 @@ void DreamMaterialCollector::Collect(base::Time window_start, } } // Top-N domains by foreground attention, then visit count. + const int domain_count = static_cast(by_domain.size()); + const int query_count = static_cast(seen_queries.size()); std::vector> sorted( std::make_move_iterator(by_domain.begin()), std::make_move_iterator(by_domain.end())); @@ -294,14 +316,27 @@ void DreamMaterialCollector::Collect(base::Time window_start, buckets.Set(name, count); } d.Set("buckets", std::move(buckets)); + base::DictValue foreground_seconds_by_bucket; + for (auto& [name, seconds] : + agg.foreground_seconds_by_bucket) { + foreground_seconds_by_bucket.Set(name, seconds); + } + d.Set("foreground_seconds_by_bucket", + std::move(foreground_seconds_by_bucket)); domains.Append(std::move(d)); } base::ListValue query_list; for (auto& q : queries) { query_list.Append(q); } - self->OnHistoryResults(std::move(domains), - std::move(query_list)); + base::DictValue foreground_bucket_stats; + for (const auto& [name, seconds] : + foreground_seconds_by_bucket) { + foreground_bucket_stats.Set(name, seconds); + } + self->OnHistoryResults( + std::move(domains), std::move(query_list), domain_count, + query_count, std::move(foreground_bucket_stats)); }, weak_factory_.GetWeakPtr()), &history_tracker_); @@ -309,8 +344,8 @@ void DreamMaterialCollector::Collect(base::Time window_start, // Part 2: agent conversations in window. The user's questions carry the // intent; keep the first 2 user messages per session. - memory_service_->LoadRecentMessages( - 500, + memory_service_->LoadConversationMessagesInRange( + window_start_, window_end_, 500, base::BindOnce( [](base::WeakPtr self, std::vector messages) { @@ -358,6 +393,16 @@ void DreamMaterialCollector::Collect(base::Time window_start, }, weak_factory_.GetWeakPtr())); + memory_service_->CountUserConversationSessionsInRange( + window_start_, window_end_, + base::BindOnce( + [](base::WeakPtr self, int session_count) { + if (self) { + self->OnConversationSessionCountLoaded(session_count); + } + }, + weak_factory_.GetWeakPtr())); + // Part 3: known high-confidence preferences. These are existing memory, not // evidence from the current day. Keep this stricter than the generic memory // context so unconfirmed dream guesses do not feed the next dream. @@ -408,10 +453,18 @@ void DreamMaterialCollector::Collect(base::Time window_start, weak_factory_.GetWeakPtr())); } -void DreamMaterialCollector::OnHistoryResults(base::ListValue domains, - base::ListValue queries) { +void DreamMaterialCollector::OnHistoryResults( + base::ListValue domains, + base::ListValue queries, + int domain_count, + int query_count, + base::DictValue foreground_seconds_by_bucket) { history_part_ = std::move(domains); search_part_ = std::move(queries); + history_domain_count_ = domain_count; + search_query_count_ = query_count; + foreground_seconds_by_bucket_part_ = + std::move(foreground_seconds_by_bucket); barrier_.Run(); } @@ -421,6 +474,12 @@ void DreamMaterialCollector::OnConversationsLoaded( barrier_.Run(); } +void DreamMaterialCollector::OnConversationSessionCountLoaded( + int session_count) { + conversation_session_count_ = session_count; + barrier_.Run(); +} + void DreamMaterialCollector::OnPreferencesLoaded(base::ListValue preferences) { preferences_part_ = std::move(preferences); barrier_.Run(); @@ -438,10 +497,9 @@ void DreamMaterialCollector::OnPartDone() { window.Set("end", FormatLocalYmdHm(window_end_)); pack.Set("window", std::move(window)); base::DictValue stats; - stats.Set("history_domains", static_cast(history_part_.size())); - stats.Set("search_queries", static_cast(search_part_.size())); - stats.Set("conversation_sessions", - static_cast(conversations_part_.size())); + stats.Set("history_domains", history_domain_count_); + stats.Set("search_queries", search_query_count_); + stats.Set("conversation_sessions", conversation_session_count_); stats.Set("preferences", static_cast(preferences_part_.size())); stats.Set("feedback_scenarios", static_cast(feedback_part_.size())); stats.Set("excluded_history_visits", excluded_history_visits_); @@ -457,6 +515,8 @@ void DreamMaterialCollector::OnPartDone() { } } stats.Set("source_domains", std::move(source_domains)); + stats.Set("foreground_seconds_by_bucket", + foreground_seconds_by_bucket_part_.Clone()); pack.Set("stats", std::move(stats)); pack.Set("history", std::move(history_part_)); pack.Set("search_queries", std::move(search_part_)); diff --git a/src/dao/browser/agent/dao_dream_material_collector.h b/src/dao/browser/agent/dao_dream_material_collector.h index c1493276..d291dd14 100644 --- a/src/dao/browser/agent/dao_dream_material_collector.h +++ b/src/dao/browser/agent/dao_dream_material_collector.h @@ -31,14 +31,18 @@ class DaoAgentMemoryService; // "history": [{"domain","visit_count","titles":[..], // "foreground_seconds":N,"total_seconds":N, // "duration_level":"light|medium|deep", -// "buckets":{"morning":N,"afternoon":N,"evening":N,"night":N}}], +// "buckets":{"morning":N,"afternoon":N,"evening":N,"night":N}, +// "foreground_seconds_by_bucket":{"morning":N, +// "afternoon":N,"evening":N,"night":N}}], // "search_queries": ["...", ...], // "conversations": [{"session_id","messages":["...",...]}], // "preferences": [{"key","value","confidence","evidence_count"}], // "feedback": [{"scenario_id","name","shown","clicked","dismissed"}], // "stats": {"history_domains":N,"search_queries":N, // "conversation_sessions":N,"preferences":N, -// "feedback_scenarios":N} +// "feedback_scenarios":N, +// "foreground_seconds_by_bucket":{"morning":N, +// "afternoon":N,"evening":N,"night":N}} // } // PRIVACY INVARIANT: no full URL appears anywhere in the output. class DreamMaterialCollector { @@ -75,8 +79,13 @@ class DreamMaterialCollector { const std::set& excluded_domains); private: - void OnHistoryResults(base::ListValue domains, base::ListValue queries); + void OnHistoryResults(base::ListValue domains, + base::ListValue queries, + int domain_count, + int query_count, + base::DictValue foreground_seconds_by_bucket); void OnConversationsLoaded(base::ListValue sessions); + void OnConversationSessionCountLoaded(int session_count); void OnPreferencesLoaded(base::ListValue preferences); void OnFeedbackLoaded(base::ListValue feedback); void OnPartDone(); @@ -94,7 +103,11 @@ class DreamMaterialCollector { base::ListValue conversations_part_; base::ListValue preferences_part_; base::ListValue feedback_part_; + base::DictValue foreground_seconds_by_bucket_part_; std::set excluded_domains_; + int history_domain_count_ = 0; + int search_query_count_ = 0; + int conversation_session_count_ = 0; int excluded_history_visits_ = 0; base::CancelableTaskTracker history_tracker_; diff --git a/src/dao/browser/agent/dao_dream_service.cc b/src/dao/browser/agent/dao_dream_service.cc index dc7271cf..a463a940 100644 --- a/src/dao/browser/agent/dao_dream_service.cc +++ b/src/dao/browser/agent/dao_dream_service.cc @@ -910,31 +910,9 @@ void DaoDreamService::MarkFailed(DreamRunRequest request, void DaoDreamService::PersistResult(const std::string& dream_date, base::DictValue result) { - // 1. Habits → MergePreference (confidence capped; contradict skipped — - // surfaced in the report only, the user adjudicates). - if (const base::ListValue* habits = result.FindList("habits")) { - for (const base::Value& h : *habits) { - const base::DictValue* habit = h.GetIfDict(); - if (!habit) { - continue; - } - const std::string* key = habit->FindString("key"); - const std::string* value = habit->FindString("value"); - const std::string* relation = habit->FindString("relation"); - if (!key || !value || key->empty() || value->empty()) { - continue; - } - if (relation && *relation == "contradict") { - continue; - } - double confidence = habit->FindDouble("confidence").value_or(0.5); - confidence = std::min(confidence, kMaxLLMConfidence); - memory_service_->MergePreference(*key, *value, confidence, - base::DoNothing()); - } - } - - // 2. Report row. + // Habit output remains a candidate until the user confirms it in the Dream + // report. Persist only the report row here; confirmation owns the memory + // write and rejection must leave existing memory untouched. DreamReport report; report.dream_date = dream_date; const std::string* md = result.FindString("report_markdown"); @@ -942,6 +920,9 @@ void DaoDreamService::PersistResult(const std::string& dream_date, if (const base::ListValue* habits = result.FindList("habits")) { base::JSONWriter::Write(*habits, &report.habit_candidates); } + if (const base::DictValue* recap = result.FindDict("recap")) { + pending_material_stats_.Set("recap", recap->Clone()); + } base::JSONWriter::Write(pending_material_stats_, &report.material_stats); report.status = "completed"; CHECK(active_request_); diff --git a/src/dao/browser/agent/dao_dream_service.h b/src/dao/browser/agent/dao_dream_service.h index 2fc0c453..ac94d9ad 100644 --- a/src/dao/browser/agent/dao_dream_service.h +++ b/src/dao/browser/agent/dao_dream_service.h @@ -237,7 +237,6 @@ class DaoDreamService : public KeyedService, static constexpr base::TimeDelta kDreamTimeout = base::Minutes(5); static constexpr base::TimeDelta kWeeklyTransientDefer = base::Hours(1); static constexpr int kMaxAttemptsPerNight = 3; // 1 initial + 2 retries - static constexpr double kMaxLLMConfidence = 0.8; raw_ptr profile_; raw_ptr memory_service_; diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts index 1a2e4038..9067cf01 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts @@ -44,6 +44,47 @@ vi.mock('../i18n/i18n.js', () => ({ 'dream.page.rerun_running': 'Dreaming...', 'dream.page.rerun_failed': 'Rerun failed: {error}', 'dream.page.title': 'Dream Report', + 'dream.page.recap_eyebrow': 'Yesterday\'s Dream Recap', + 'dream.page.recap_title': 'How was your day yesterday?', + 'dream.page.summary_label': 'In one sentence', + 'dream.page.rhythm_title': 'Your rhythm', + 'dream.page.rhythm_hint': 'By foreground focus time', + 'dream.page.rhythm_morning': 'Morning', + 'dream.page.rhythm_afternoon': 'Afternoon', + 'dream.page.rhythm_evening': 'Evening', + 'dream.page.rhythm_night': 'Late night', + 'dream.page.minutes': '{count} min', + 'dream.page.themes_title': 'What you did', + 'dream.page.themes_count': '{count} themes', + 'dream.page.theme_light': 'Light', + 'dream.page.theme_medium': 'Focused', + 'dream.page.theme_deep': 'Deep', + 'dream.page.stats_domains': 'domains visited', + 'dream.page.stats_searches': 'searches', + 'dream.page.stats_conversations': 'AI conversations', + 'dream.page.memory_title': 'Remember these?', + 'dream.page.memory_hint': 'Confirmed items become long-term memory', + 'dream.page.memory_new': 'New', + 'dream.page.memory_reinforce': 'Confirmed', + 'dream.page.memory_contradict': 'Changed', + 'dream.page.memory_existing': 'Already in memory', + 'dream.page.full_report': 'Expand full written recap', + 'dream.page.activity_title': 'Past year activity', + 'dream.page.activity_reports': '{count} reports', + 'dream.page.activity_less': 'Less', + 'dream.page.activity_more': 'More', + 'dream.page.activity_label': 'Daily Dream report activity', + 'dream.page.weekly_badge': 'Weekly', + 'dream.page.weekly_eyebrow': 'Weekly Dream Recap', + 'dream.page.weekly_period': '{start} – {end}', + 'dream.page.weekly_primary_thread': 'Primary thread', + 'dream.page.weekly_threads': 'Threads to continue', + 'dream.page.weekly_secondary_threads': 'Supporting threads', + 'dream.page.weekly_next_step': 'Next step', + 'dream.page.weekly_outcomes': 'Retained outcomes', + 'dream.page.weekly_time_pattern': 'Weekly footprint', + 'dream.page.weekly_sources': '{count} sources', + 'dream.page.history_recent': 'Recent {count} days', 'dream.page.source_domains_title': 'Domains used in this report', 'dream.page.source_domains_add': 'Add to blacklist', 'dream.page.source_domains_confirm': @@ -92,6 +133,64 @@ function report( }; } +function weeklyReport(weekStart: string) { + return { + reportKind: 'weekly', + id: 701, + weekStart, + weekEnd: '2026-08-10', + content: { + schema_version: 1, + headline: 'A week of shipping the Dream redesign', + primary_thread: { + title: 'Dream report redesign', + status_summary: 'The visual implementation and data contract landed.', + next_step: 'Run the native visual acceptance pass.', + confidence: 0.9, + source_refs: ['page_1'], + }, + secondary_threads: [], + retained_outcomes: [{ + text: 'The WebUI suite remained green.', + confidence: 0.9, + source_refs: ['conversation_1'], + }], + footprint_summary: { + themes: ['Dream', 'Chromium'], + time_pattern: 'Focused work clustered in the afternoon.', + }, + }, + materialStats: '{"source_count":2}', + triggerKind: 'scheduled_weekly', + sourceCount: 2, + createdAt: Date.UTC(2026, 7, 10, 6, 0), + }; +} + +function recapMaterialStats() { + return JSON.stringify({ + history_domains: 14, + search_queries: 7, + conversation_sessions: 3, + recap: { + summary: 'Afternoon focus shifted into an evening architecture read.', + time_buckets: { + morning_minutes: 22, + afternoon_minutes: 125, + evening_minutes: 78, + night_minutes: 9, + }, + themes: [{ + name: 'Rust async programming', + summary: 'Read Tokio and async runtime documentation deeply.', + intensity: 'deep', + time_label: 'Mostly afternoon', + attention_share: 100, + }], + }, + }); +} + function habitCandidates() { return JSON.stringify([{ key: 'preferred_search', @@ -116,9 +215,13 @@ async function mountDreamApp(pathname: string): Promise { window.history.pushState({}, '', pathname); const el = document.createElement('dao-dream-app') as TestDreamApp; document.body.appendChild(el); - await el.updateComplete; - await Promise.resolve(); - await el.updateComplete; + for (let attempt = 0; attempt < 8; attempt++) { + await Promise.resolve(); + await el.updateComplete; + if (!el.shadowRoot!.querySelector('.status')) { + break; + } + } return el; } @@ -164,9 +267,9 @@ describe('dao-dream-app routing', () => { const el = await mountDreamApp('/'); expect(bridgeMocks.callNative).toHaveBeenCalledWith( - 'getDreamReports', {limit: 30}); + 'getDreamReports', {limit: 371}); expect(el.shadowRoot!.textContent).toContain('2026-06-12'); - expect(el.shadowRoot!.textContent).toContain('2026-06-11'); + expect(el.shadowRoot!.textContent).toContain('Thu, Jun 11'); }); it('loads dream history for dao://dream/history', async () => { @@ -175,7 +278,7 @@ describe('dao-dream-app routing', () => { const el = await mountDreamApp('/history'); expect(bridgeMocks.callNative).toHaveBeenCalledWith( - 'getDreamReports', {limit: 30}); + 'getDreamReports', {limit: 371}); expect(el.shadowRoot!.textContent).toContain('2026-06-10'); }); @@ -186,7 +289,7 @@ describe('dao-dream-app routing', () => { expect(bridgeMocks.callNative).toHaveBeenCalledWith('getTodayDreamReport'); expect(bridgeMocks.callNative).not.toHaveBeenCalledWith( - 'getDreamReports', {limit: 30}); + 'getDreamReports', {limit: 371}); expect(el.shadowRoot!.textContent).toContain('2026-06-13'); }); @@ -220,9 +323,118 @@ describe('dao-dream-app routing', () => { expect(bridgeMocks.callNative).toHaveBeenCalledWith( 'startManualDream', {date: '2026-06-19'}, {timeoutMs: 360000}); expect(bridgeMocks.callNative).toHaveBeenCalledWith( - 'getDreamReports', {limit: 30}); + 'getDreamReports', {limit: 371}); + }); + + it('includes selectable weekly reports in the shared history rail', + async () => { + bridgeMocks.callNative.mockImplementation(async (method: string) => { + if (method === 'getDreamReports') { + return [report('2026-08-09')]; + } + if (method === 'getWeeklyDreamReports') { + return [weeklyReport('2026-08-03')]; + } + return true; + }); + + const el = await mountDreamApp('/'); + expect(bridgeMocks.callNative).toHaveBeenCalledWith( + 'getWeeklyDreamReports', {limit: 53}); + const weeklyButton = el.shadowRoot!.querySelector( + '.history-item[data-report-kind="weekly"]'); + expect(weeklyButton).toBeTruthy(); + + weeklyButton!.click(); + await el.updateComplete; + + expect(el.shadowRoot!.querySelector('.weekly-recap')).toBeTruthy(); + expect(el.shadowRoot!.textContent).toContain( + 'A week of shipping the Dream redesign'); + expect(el.shadowRoot!.textContent).toContain( + 'Run the native visual acceptance pass.'); + expect(bridgeMocks.callNative).toHaveBeenCalledWith( + 'markWeeklyDreamReportViewed', {reportId: 701}); + }); + + it('renders the selected one-minute recap design from structured data', + async () => { + bridgeMocks.callNative.mockResolvedValueOnce([ + report('2026-06-19', habitCandidates(), recapMaterialStats()), + report('2026-06-18'), + ]); + + const el = await mountDreamApp('/'); + const root = el.shadowRoot!; + + expect(root.querySelector('.activity-heatmap')).toBeTruthy(); + expect(root.querySelectorAll('.heat-cell').length).toBeGreaterThan(350); + expect(root.querySelectorAll('.history-item')).toHaveLength(2); + expect(root.querySelector('.recap-summary')?.textContent).toContain( + 'Afternoon focus shifted'); + expect(root.querySelectorAll('.rhythm-slot')).toHaveLength(4); + expect(root.querySelector( + '.rhythm-slot[data-peak="true"]')?.textContent) + .toContain('125 min'); + expect(root.querySelectorAll('.theme-card')).toHaveLength(1); + expect(root.querySelector('.theme-card')?.textContent) + .toContain('Rust async programming'); + expect(root.querySelector('.recap-stats')?.textContent) + .toContain('14'); + expect(root.querySelector('.recap-stats')?.textContent) + .toContain('7'); + expect(root.querySelector('.recap-stats')?.textContent) + .toContain('3'); + expect(root.querySelector('details.full-report')).toBeTruthy(); + expect(root.querySelector('.memory-candidates')).toBeTruthy(); + }); + + it('derives a concise recap fallback from legacy markdown', async () => { + bridgeMocks.callNative.mockResolvedValueOnce([{ + ...report('2026-06-19'), + reportMarkdown: '## Main thread\nBuilt the new Dream report.\n\n## Note\nKept the old data path.', + }]); + + const el = await mountDreamApp('/'); + + expect(el.shadowRoot!.querySelector('.recap-summary')?.textContent) + .toContain('Built the new Dream report.'); + expect(el.shadowRoot!.querySelectorAll('.theme-card')).toHaveLength(2); + expect(el.shadowRoot!.querySelector('.theme-card')?.textContent) + .toContain('Main thread'); }); + it('uses measured foreground buckets instead of model-estimated rhythm', + async () => { + const stats = JSON.parse(recapMaterialStats()); + stats.foreground_seconds_by_bucket = { + morning: 600, + afternoon: 7200, + evening: 1800, + night: 0, + }; + stats.recap.time_buckets = { + morning_minutes: 999, + afternoon_minutes: 999, + evening_minutes: 999, + night_minutes: 999, + }; + bridgeMocks.callNative.mockResolvedValueOnce([ + report('2026-06-19', '[]', JSON.stringify(stats)), + ]); + + const el = await mountDreamApp('/'); + const slots = Array.from( + el.shadowRoot!.querySelectorAll('.rhythm-slot')); + + expect(slots.map(slot => slot.textContent)).toEqual([ + expect.stringContaining('10 min'), + expect.stringContaining('120 min'), + expect.stringContaining('30 min'), + expect.stringContaining('0 min'), + ]); + }); + it('lists report source domains and adds one to exclusions', async () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); bridgeMocks.callNative.mockResolvedValueOnce([ @@ -272,7 +484,7 @@ describe('dao-dream-app routing', () => { .toBe(true); const habitHeading = Array.from(el.shadowRoot!.querySelectorAll('h2')) .find(node => node.textContent?.includes( - 'I think I noticed')); + 'Remember these?')); expect(habitHeading).toBeTruthy(); expect(Boolean( habitHeading!.compareDocumentPosition(picker!) & @@ -371,9 +583,8 @@ describe('dao-dream-app routing', () => { ]); const el = await mountDreamApp('/'); - const confirmButton = Array.from(el.shadowRoot!.querySelectorAll('button')) - .find(button => button.textContent?.includes( - 'chat.dream.habit_confirm')); + const confirmButton = el.shadowRoot!.querySelector( + 'button[aria-label="chat.dream.habit_confirm"]'); expect(confirmButton).toBeTruthy(); confirmButton!.click(); @@ -394,6 +605,34 @@ describe('dao-dream-app routing', () => { 'chat.dream.habit_reject'); }); + it('rejects a changed memory candidate without deleting existing memory', + async () => { + bridgeMocks.callNative.mockResolvedValueOnce([ + report('2026-06-12', JSON.stringify([{ + key: 'preferred_search', + value: 'Now prefers source code over documentation search', + confidence: 0.7, + evidence: 'Opened source files directly', + relation: 'contradict', + }])), + ]); + + const el = await mountDreamApp('/'); + const rejectButton = el.shadowRoot!.querySelector( + 'button[aria-label="chat.dream.habit_reject"]'); + expect(rejectButton).toBeTruthy(); + + rejectButton!.click(); + await el.updateComplete; + + expect(el.shadowRoot!.textContent).toContain( + 'chat.dream.habit_rejected'); + expect(bridgeMocks.callNative).not.toHaveBeenCalledWith( + 'getPreferences'); + expect(bridgeMocks.callNativeArgs).not.toHaveBeenCalledWith( + 'deleteMemory', expect.anything(), expect.anything()); + }); + it('copies the current dream report as an image', async () => { bridgeMocks.callNative.mockResolvedValueOnce([ { diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts index dfd780b4..40427d0c 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts @@ -53,6 +53,22 @@ function respondWith(content: string, usage?: { const VALID = JSON.stringify({ report_markdown: '昨晚的报告', + recap: { + summary: '下午专注 Rust,晚上阅读浏览器架构。', + time_buckets: { + morning_minutes: 22, + afternoon_minutes: 125, + evening_minutes: 78, + night_minutes: 9, + }, + themes: [{ + name: 'Rust 异步编程', + summary: '围绕 tokio 与 async runtime 深入阅读。', + intensity: 'deep', + time_label: '下午为主', + attention_share: 100, + }], + }, habits: [{ key: 'interest.rust', value: '你最近在关注 Rust 异步编程。', @@ -88,6 +104,16 @@ describe('runDream', () => { respondWith(VALID); const result = await runDream('2026-06-11', {history: []}); expect(result.report_markdown).toBe('昨晚的报告'); + expect(result.recap.summary).toBe( + '下午专注 Rust,晚上阅读浏览器架构。'); + expect(result.recap.time_buckets.afternoon_minutes).toBe(125); + expect(result.recap.themes).toEqual([{ + name: 'Rust 异步编程', + summary: '围绕 tokio 与 async runtime 深入阅读。', + intensity: 'deep', + time_label: '下午为主', + attention_share: 100, + }]); expect(result.habits).toHaveLength(1); expect(result.habits[0]!.confidence).toBe(0.8); expect(result.scenario_adjustments).toHaveLength(1); @@ -143,6 +169,8 @@ describe('runDream', () => { expect(systemPrompt).toContain( 'summarize by topic, intent, and time pattern'); expect(systemPrompt).toContain('Use foreground_seconds'); + expect(systemPrompt).toContain('stats.foreground_seconds_by_bucket'); + expect(systemPrompt).toContain('visit-count-only buckets'); expect(systemPrompt).toContain('deep'); expect(systemPrompt).toContain('light'); expect(systemPrompt).toContain('Never expose'); @@ -212,4 +240,61 @@ describe('runDream', () => { expect(result.habits).toHaveLength(1); expect(result.habits[0]!.key).toBe('k2'); }); + + it('drops malformed recap themes and clamps numeric recap values', + async () => { + respondWith(JSON.stringify({ + report_markdown: 'r', + recap: { + summary: 'A focused day.', + time_buckets: { + morning_minutes: -3, + afternoon_minutes: 25.8, + evening_minutes: 'invalid', + night_minutes: 5, + }, + themes: [ + {name: 'Missing summary'}, + { + name: 'Architecture', + summary: 'Read rendering documentation.', + intensity: 'unknown', + time_label: 'Evening', + attention_share: 140, + }, + ], + }, + habits: [], + scenario_adjustments: [], + })); + + const result = await runDream('2026-06-11', {}); + + expect(result.recap.time_buckets).toEqual({ + morning_minutes: 0, + afternoon_minutes: 26, + evening_minutes: 0, + night_minutes: 5, + }); + expect(result.recap.themes).toEqual([{ + name: 'Architecture', + summary: 'Read rendering documentation.', + intensity: 'medium', + time_label: 'Evening', + attention_share: 100, + }]); + }); + + it('leaves a missing recap summary empty for the UI fallback', async () => { + respondWith(JSON.stringify({ + report_markdown: '## Main thread\nBuilt the new Dream report.\n\n' + + '## Note\nKept the old data path.', + habits: [], + scenario_adjustments: [], + })); + + const result = await runDream('2026-06-11', {}); + + expect(result.recap.summary).toBe(''); + }); }); diff --git a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts index 7b1ffbdf..b9cdb7ac 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts @@ -20,17 +20,79 @@ interface DreamHabit { relation: 'new'|'reinforce'|'contradict'; } -interface DreamReportData { +interface DreamTheme { + name: string; + summary: string; + intensity: 'light'|'medium'|'deep'; + timeLabel: string; + attentionShare: number; +} + +interface DreamRecap { + summary: string; + timeBuckets: { + morning: number; + afternoon: number; + evening: number; + night: number; + }; + themes: DreamTheme[]; +} + +interface DreamMaterialStats { + historyDomains: number; + searchQueries: number; + conversationSessions: number; + sourceDomains: string[]; + recap: DreamRecap; +} + +interface DailyDreamReportData { + reportKind: 'daily'; id: number; dreamDate: string; reportMarkdown: string; habits: DreamHabit[]; materialStats: string; + stats: DreamMaterialStats; debugMaterialJson: string; triggerKind: string; createdAt?: number; } +interface WeeklyDreamThread { + title: string; + statusSummary: string; + nextStep: string; + confidence: number; + sourceRefs: string[]; +} + +interface WeeklyDreamOutcome { + text: string; + confidence: number; + sourceRefs: string[]; +} + +interface WeeklyDreamReportData { + reportKind: 'weekly'; + id: number; + weekStart: string; + weekEnd: string; + headline: string; + primaryThread: WeeklyDreamThread; + secondaryThreads: WeeklyDreamThread[]; + retainedOutcomes: WeeklyDreamOutcome[]; + footprintThemes: string[]; + timePattern: string; + materialStats: string; + triggerKind: string; + sourceCount: number; + createdAt?: number; +} + +type DreamReportData = DailyDreamReportData|WeeklyDreamReportData; + type HabitState = 'confirmed'|'rejected'; type ShareStatus = 'idle'|'copying'|'copied'|'failed'; @@ -47,6 +109,7 @@ export class DaoDreamApp extends CrLitElement { loading_: {type: Boolean, state: true}, report_: {type: Object, state: true}, reports_: {type: Array, state: true}, + weeklyReports_: {type: Array, state: true}, error_: {type: String, state: true}, habitStates_: {type: Object, state: true}, shareStatus_: {type: String, state: true}, @@ -60,7 +123,8 @@ export class DaoDreamApp extends CrLitElement { declare private loading_: boolean; declare private report_: DreamReportData|null; - declare private reports_: DreamReportData[]; + declare private reports_: DailyDreamReportData[]; + declare private weeklyReports_: WeeklyDreamReportData[]; declare private error_: string; declare private habitStates_: Record; declare private shareStatus_: ShareStatus; @@ -75,6 +139,7 @@ export class DaoDreamApp extends CrLitElement { this.loading_ = true; this.report_ = null; this.reports_ = []; + this.weeklyReports_ = []; this.error_ = ''; this.habitStates_ = {}; this.shareStatus_ = 'idle'; @@ -430,90 +495,881 @@ export class DaoDreamApp extends CrLitElement { cursor: pointer; } - button:hover { - background: rgba(255, 255, 255, 0.92); - border-color: rgba(70, 120, 190, 0.42); + button:hover { + background: rgba(255, 255, 255, 0.92); + border-color: rgba(70, 120, 190, 0.42); + } + + .state { + color: rgba(30, 20, 40, 0.52); + font-size: 12px; + line-height: 30px; + } + + details { + border-top: 1px solid rgba(70, 120, 190, 0.16); + padding-top: 18px; + } + + summary { + color: rgba(30, 20, 40, 0.62); + cursor: pointer; + font-size: 13px; + } + + pre { + max-height: 420px; + overflow: auto; + margin: 12px 0 0; + padding: 12px; + border-radius: 8px; + background: rgba(70, 120, 190, 0.08); + color: rgba(30, 20, 40, 0.72); + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + } + + .debug-meta { + margin: 12px 0 0; + color: rgba(30, 20, 40, 0.58); + font-size: 12px; + } + + @media (max-width: 640px) { + .page { + width: min(100vw - 28px, 880px); + padding-top: 28px; + } + + header { + display: block; + } + + h1 { + font-size: 28px; + } + + .date { + display: block; + margin-top: 10px; + white-space: normal; + } + + .header-actions { + justify-content: flex-start; + } + + .habit { + display: block; + } + + .history-layout { + display: block; + } + + .history-list { + border-right: 0; + border-bottom: 1px solid rgba(70, 120, 190, 0.14); + margin-bottom: 22px; + padding: 0 0 16px; + } + + .actions { + justify-content: flex-start; + margin-top: 10px; + } + } + + :host { + --dream-ink: 30, 20, 40; + --dream-accent: 70, 120, 190; + --dream-bg: rgb(238, 243, 248); + --dream-bg-soft: rgb(249, 251, 253); + --dream-surface: rgba(255, 255, 255, 0.66); + --dream-surface-strong: rgba(255, 255, 255, 0.9); + --dream-border: rgba(var(--dream-accent), 0.16); + --dream-border-soft: rgba(var(--dream-ink), 0.08); + --dream-fg: rgba(var(--dream-ink), 0.9); + --dream-fg-2: rgba(var(--dream-ink), 0.66); + --dream-fg-3: rgba(var(--dream-ink), 0.48); + color: var(--dream-fg); + background: + radial-gradient(1200px 520px at 78% -10%, + rgba(var(--dream-accent), 0.1), transparent 60%), + linear-gradient(180deg, var(--dream-bg) 0%, + var(--dream-bg-soft) 44%), + var(--dream-bg-soft); + background-attachment: fixed; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + -webkit-font-smoothing: antialiased; + } + + .page { + width: min(860px, calc(100vw - 56px)); + padding: 30px 0 72px; + } + + main { + padding: 0; + } + + section, + details { + border-top: 0; + padding-top: 0; + } + + .history-layout { + grid-template-columns: minmax(196px, 234px) minmax(0, 1fr); + gap: 34px; + } + + .history-rail { + position: sticky; + top: 24px; + display: flex; + min-width: 0; + flex-direction: column; + gap: 20px; + padding-right: 18px; + border-right: 1px solid rgba(var(--dream-accent), 0.14); + } + + .activity-heatmap { + margin: 0; + padding: 15px 15px 13px; + overflow: hidden; + border: 1px solid var(--dream-border-soft); + border-radius: 14px; + background: var(--dream-surface); + backdrop-filter: blur(14px); + } + + .heatmap-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + margin-bottom: 12px; + } + + .heatmap-header h2, + .history-heading h2, + .block-title h2 { + margin: 0; + color: var(--dream-fg-2); + font-size: 13px; + font-weight: 620; + letter-spacing: 0.02em; + } + + .heatmap-header span, + .history-heading span, + .block-title span { + color: var(--dream-fg-3); + font-size: 11px; + font-variant-numeric: tabular-nums; + } + + .heatmap-scroll { + overflow-x: auto; + scrollbar-width: none; + } + + .heatmap-scroll::-webkit-scrollbar { + display: none; + } + + .heatmap-months { + display: grid; + grid-template-columns: repeat(53, 11px); + gap: 3px; + width: max-content; + height: 12px; + color: var(--dream-fg-3); + font-size: 9px; + } + + .heatmap-months span { + white-space: nowrap; + } + + .heatmap-grid { + display: grid; + grid-template-columns: repeat(53, 11px); + grid-template-rows: repeat(7, 11px); + grid-auto-flow: column; + gap: 3px; + width: max-content; + } + + .heat-cell { + width: 11px; + min-height: 11px; + height: 11px; + padding: 0; + border: 0; + border-radius: 2.5px; + background: rgba(var(--dream-ink), 0.07); + } + + .heat-cell:disabled { + opacity: 1; + cursor: default; + } + + .heat-cell:not(:disabled):hover, + .heat-cell:not(:disabled):focus-visible { + outline: 2px solid rgb(var(--dream-accent)); + outline-offset: 1px; + } + + .heatmap-legend { + display: flex; + align-items: center; + gap: 4px; + margin-top: 12px; + color: var(--dream-fg-3); + font-size: 10px; + } + + .heatmap-legend i { + width: 11px; + height: 11px; + border-radius: 2.5px; + background: rgba(var(--dream-ink), 0.07); + } + + [data-level="1"] { background: rgba(var(--dream-accent), 0.28); } + [data-level="2"] { background: rgba(var(--dream-accent), 0.5); } + [data-level="3"] { background: rgba(var(--dream-accent), 0.74); } + [data-level="4"] { background: rgb(var(--dream-accent)); } + + .history-list { + min-width: 0; + margin: 0; + padding: 0; + border: 0; + } + + .history-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + margin: 0 0 12px; + padding: 0 2px; + } + + .history-heading h2 { + color: var(--dream-fg-3); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .history-item { + display: flex; + align-items: center; + gap: 9px; + min-height: 0; + margin: 0 0 1px; + padding: 7px 10px; + border: 1px solid transparent; + border-radius: 10px; + color: inherit; + } + + .history-item:hover { + background: rgba(var(--dream-accent), 0.06); + } + + .history-item[data-selected="true"] { + border-color: rgba(var(--dream-accent), 0.22); + background: rgba(var(--dream-accent), 0.1); + } + + .history-item:focus-visible { + outline: 2px solid rgb(var(--dream-accent)); + outline-offset: -2px; + } + + .history-dot { + flex: 0 0 9px; + width: 9px; + height: 9px; + border-radius: 50%; + } + + .history-copy { + flex: 1 1 auto; + min-width: 0; + } + + .history-kind-badge { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 999px; + background: rgba(var(--dream-accent), 0.12); + color: rgb(var(--dream-accent)); + font-size: 9px; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .history-date, + .history-kind { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .history-date { + font-size: 13px; + font-weight: 550; + } + + .history-item[data-selected="true"] .history-date { + color: rgb(var(--dream-accent)); + font-weight: 620; + } + + .history-kind { + margin: 0; + color: var(--dream-fg-3); + font-size: 11px; + } + + .recap-article { + display: block; + min-width: 0; + } + + .recap-header { + align-items: baseline; + margin: 0 0 22px; + padding: 0; + border: 0; + } + + .recap-header .eyebrow { + display: inline-flex; + align-items: center; + gap: 7px; + margin: 0; + color: rgb(var(--dream-accent)); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .recap-header .eyebrow svg { + width: 15px; + height: 15px; + } + + .recap-header h1 { + margin: 8px 0 0; + font-size: 26px; + line-height: 1.15; + font-weight: 640; + letter-spacing: -0.01em; + } + + .header-actions { + flex-wrap: nowrap; + } + + .date { + color: var(--dream-fg-3); + font-size: 13px; + } + + .copy-image-button, + .rerun-report-button, + .habit-action { + background: var(--dream-surface-strong); + color: var(--dream-fg-2); + } + + .recap-summary { + margin: 0 0 26px; + padding: 22px 26px; + border: 1px solid var(--dream-border); + border-radius: 14px; + background: var(--dream-surface); + box-shadow: 0 10px 30px rgba(var(--dream-ink), 0.05); + backdrop-filter: blur(14px); + } + + .recap-summary > div { + margin-bottom: 10px; + color: var(--dream-fg-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .recap-summary p { + margin: 0; + font-size: 21px; + line-height: 1.45; + font-weight: 500; + letter-spacing: -0.005em; + } + + .recap-block, + .memory-section { + margin: 0 0 26px; + } + + .block-title { + display: flex; + align-items: baseline; + justify-content: space-between; + margin: 0 0 12px; + } + + .rhythm-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + } + + .rhythm-slot { + position: relative; + display: flex; + min-height: 96px; + flex-direction: column; + justify-content: flex-end; + padding: 12px 12px 14px; + overflow: hidden; + border: 1px solid var(--dream-border-soft); + border-radius: 10px; + background: var(--dream-surface); + } + + .rhythm-slot[data-peak="true"] { + border-color: var(--dream-border); + } + + .rhythm-fill { + position: absolute; + right: 0; + bottom: 0; + left: 0; + background: linear-gradient(180deg, + rgba(var(--dream-accent), 0.16), + rgba(var(--dream-accent), 0.28)); + } + + .rhythm-meta { + position: relative; + display: flex; + flex-direction: column; + } + + .rhythm-meta strong { + font-size: 12px; + font-weight: 600; + } + + .rhythm-slot[data-peak="true"] strong { + color: rgb(var(--dream-accent)); + } + + .rhythm-meta span { + margin-top: 2px; + color: var(--dream-fg-3); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + .theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 12px; + } + + .theme-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 16px 14px; + border: 1px solid var(--dream-border-soft); + border-radius: 14px; + background: var(--dream-surface); + transition: border-color 140ms ease, transform 140ms ease, + box-shadow 140ms ease; + } + + .theme-card:hover { + border-color: var(--dream-border); + box-shadow: 0 12px 26px rgba(var(--dream-ink), 0.06); + transform: translateY(-2px); + } + + .theme-header { + display: flex; + align-items: center; + gap: 10px; + } + + .theme-icon { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 34px; + place-items: center; + border-radius: 9px; + background: rgba(var(--dream-accent), 0.1); + color: rgb(var(--dream-accent)); + } + + .theme-icon svg { + width: 18px; + height: 18px; + } + + .theme-header strong { + font-size: 15px; + font-weight: 600; + } + + .theme-card p { + margin: 0; + color: var(--dream-fg-2); + font-size: 13px; + line-height: 1.5; + } + + .weekly-next-step { + padding-top: 10px; + border-top: 1px solid var(--dream-border-soft); + } + + .weekly-next-step span { + color: rgb(var(--dream-accent)); + font-size: 10px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .weekly-next-step p { + margin-top: 5px; + color: var(--dream-fg); + } + + .weekly-footprint p { + margin: 0; + color: var(--dream-fg-2); + font-size: 14px; + line-height: 1.6; + } + + .theme-footer { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .theme-chip { + padding: 3px 9px; + border-radius: 999px; + background: rgba(var(--dream-ink), 0.05); + color: var(--dream-fg-2); + font-size: 11px; + font-weight: 550; + white-space: nowrap; + } + + .theme-chip.intensity { + background: rgba(var(--dream-accent), 0.1); + color: rgb(var(--dream-accent)); + } + + .theme-bar { + height: 6px; + flex: 1 1 70px; + overflow: hidden; + border-radius: 999px; + background: rgba(var(--dream-ink), 0.08); + } + + .theme-bar i { + display: block; + height: 100%; + border-radius: inherit; + background: rgb(var(--dream-accent)); + } + + .recap-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 0 0 26px; + } + + .stat-card { + padding: 16px 18px; + border: 1px solid var(--dream-border-soft); + border-radius: 14px; + background: var(--dream-surface); + } + + .stat-card strong { + display: block; + font-size: 30px; + line-height: 1; + font-weight: 640; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + } + + .stat-card span { + display: block; + margin-top: 7px; + color: var(--dream-fg-3); + font-size: 12px; + } + + .memory-candidates { + overflow: hidden; + border: 1px solid var(--dream-border-soft); + border-radius: 14px; + background: var(--dream-surface); + } + + .habit { + padding: 14px 16px; + border: 0; + border-top: 1px solid var(--dream-border-soft); + } + + .habit:first-child { + border-top: 0; + } + + .habit-relation { + display: inline-block; + margin-bottom: 6px; + padding: 2px 7px; + border-radius: 5px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .habit-relation[data-relation="new"] { + background: rgba(var(--dream-accent), 0.1); + color: rgb(var(--dream-accent)); + } + + .habit-relation[data-relation="reinforce"] { + background: rgba(var(--dream-ink), 0.06); + color: var(--dream-fg-2); + } + + .habit-relation[data-relation="contradict"] { + background: rgba(160, 82, 45, 0.12); + color: rgb(160, 82, 45); + } + + .habit-action { + display: grid; + width: 30px; + height: 30px; + padding: 0; + place-items: center; + } + + .habit-action svg { + width: 15px; + height: 15px; + } + + .full-report { + margin-top: 26px; + padding-top: 16px; + border-top: 1px solid var(--dream-border-soft); + } + + .full-report summary { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--dream-fg-2); + font-size: 13px; + font-weight: 550; + list-style: none; + } + + .full-report summary::-webkit-details-marker { + display: none; + } + + .full-report summary svg { + width: 14px; + height: 14px; + transition: transform 180ms ease; + } + + .full-report[open] summary svg { + transform: rotate(90deg); } - .state { - color: rgba(30, 20, 40, 0.52); - font-size: 12px; - line-height: 30px; + .report-body { + max-width: 66ch; + margin-top: 14px; + color: var(--dream-fg-2); } - details { - border-top: 1px solid rgba(70, 120, 190, 0.16); - padding-top: 18px; + .markdown { + font-size: 14px; + line-height: 1.72; } - summary { - color: rgba(30, 20, 40, 0.62); - cursor: pointer; - font-size: 13px; + .report-utilities { + margin-top: 24px; } - pre { - max-height: 420px; - overflow: auto; - margin: 12px 0 0; - padding: 12px; - border-radius: 8px; - background: rgba(70, 120, 190, 0.08); - color: rgba(30, 20, 40, 0.72); - font-size: 12px; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; + .report-domain-picker, + .debug-details { + margin-top: 14px; + padding-top: 16px; + border-top: 1px solid var(--dream-border-soft); } - .debug-meta { - margin: 12px 0 0; - color: rgba(30, 20, 40, 0.58); - font-size: 12px; + @media (prefers-color-scheme: dark) { + :host { + --dream-ink: 245, 245, 245; + --dream-bg: rgb(40, 44, 49); + --dream-bg-soft: rgb(46, 50, 56); + --dream-surface: rgba(255, 255, 255, 0.06); + --dream-surface-strong: rgba(255, 255, 255, 0.1); + --dream-border: rgba(var(--dream-accent), 0.34); + --dream-border-soft: rgba(255, 255, 255, 0.09); + --dream-fg: rgba(var(--dream-ink), 0.92); + --dream-fg-2: rgba(var(--dream-ink), 0.66); + --dream-fg-3: rgba(var(--dream-ink), 0.46); + } + + .habit-relation[data-relation="contradict"] { + background: rgba(224, 152, 108, 0.16); + color: rgb(224, 152, 108); + } } - @media (max-width: 640px) { + @media (max-width: 760px) { .page { - width: min(100vw - 28px, 880px); - padding-top: 28px; + width: calc(100vw - 40px); } - header { - display: block; + .history-layout { + grid-template-columns: 1fr; + gap: 20px; } - h1 { - font-size: 28px; + .history-rail { + position: static; + gap: 16px; + padding: 0; + border-right: 0; } - .date { - display: block; - margin-top: 10px; - white-space: normal; + .history-list { + padding-bottom: 16px; + border-bottom: 1px solid rgba(var(--dream-accent), 0.14); + } + + .history-items { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: none; + } + + .history-items::-webkit-scrollbar { + display: none; + } + + .history-items > button { + width: 132px; + flex: 0 0 132px; + margin: 0; + border-color: var(--dream-border-soft); + } + } + + @media (max-width: 640px) { + .page { + width: calc(100vw - 28px); + padding-top: 22px; + } + + .recap-header { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + + .recap-header h1 { + font-size: 24px; } .header-actions { justify-content: flex-start; } - .habit { - display: block; + .date { + margin: 0; } - .history-layout { - display: block; + .recap-summary { + padding: 19px 20px; } - .history-list { - border-right: 0; - border-bottom: 1px solid rgba(70, 120, 190, 0.14); - margin-bottom: 22px; - padding: 0 0 16px; + .recap-summary p { + font-size: 18px; } - .actions { - justify-content: flex-start; - margin-top: 10px; + .rhythm-grid { + gap: 7px; + } + + .rhythm-slot { + min-height: 88px; + padding: 10px 8px 12px; + } + + .recap-stats { + gap: 8px; + } + + .stat-card { + padding: 14px 12px; + } + + .stat-card strong { + font-size: 26px; + } + } + + @media (prefers-reduced-motion: reduce) { + .theme-card, + .full-report summary svg { + transition: none; } } `; @@ -548,6 +1404,7 @@ export class DaoDreamApp extends CrLitElement { const report = this.normalizeReport_(raw); this.report_ = report; this.reports_ = []; + this.weeklyReports_ = []; this.habitStates_ = report ? this.loadPersistedHabitStates_(report) : {}; if (report) { this.markReportViewed_(report); @@ -556,17 +1413,23 @@ export class DaoDreamApp extends CrLitElement { this.error_ = e instanceof Error ? e.message : String(e); this.report_ = null; this.reports_ = []; + this.weeklyReports_ = []; } } private async loadHistory_() { try { - const raw = await callNative('getDreamReports', {limit: 30}); + const [raw, rawWeekly] = await Promise.all([ + callNative('getDreamReports', {limit: 371}), + callNative('getWeeklyDreamReports', {limit: 53}), + ]); const reports = this.normalizeReports_(raw); + const weeklyReports = this.normalizeWeeklyReports_(rawWeekly); this.reports_ = reports; - this.report_ = reports[0] || null; - this.habitStates_ = - this.report_ ? this.loadPersistedHabitStates_(this.report_) : {}; + this.weeklyReports_ = weeklyReports; + this.report_ = this.historyReports_()[0] || null; + this.habitStates_ = this.report_?.reportKind === 'daily' ? + this.loadPersistedHabitStates_(this.report_) : {}; if (this.report_) { this.markReportViewed_(this.report_); } @@ -574,10 +1437,11 @@ export class DaoDreamApp extends CrLitElement { this.error_ = e instanceof Error ? e.message : String(e); this.report_ = null; this.reports_ = []; + this.weeklyReports_ = []; } } - private normalizeReport_(raw: unknown): DreamReportData|null { + private normalizeReport_(raw: unknown): DailyDreamReportData|null { const r = raw as { id?: number; dreamDate?: string; @@ -604,11 +1468,14 @@ export class DaoDreamApp extends CrLitElement { habits = []; } return { + reportKind: 'daily', id: r.id, dreamDate: r.dreamDate || '', reportMarkdown: r.reportMarkdown || '', habits, materialStats: r.materialStats || '', + stats: this.normalizeMaterialStats_( + r.materialStats || '', r.reportMarkdown || ''), debugMaterialJson: r.debugMaterialJson || '', triggerKind: r.triggerKind || '', createdAt: @@ -617,22 +1484,268 @@ export class DaoDreamApp extends CrLitElement { }; } - private normalizeReports_(raw: unknown): DreamReportData[] { + private boundedNumber_(value: unknown, max: number): number { + return typeof value === 'number' && Number.isFinite(value) ? + Math.min(Math.max(Math.round(value), 0), max) : 0; + } + + private markdownSections_(markdown: string): + Array<{title: string; body: string}> { + const sections: Array<{title: string; body: string}> = []; + let title = ''; + let body: string[] = []; + const flush = () => { + const text = body.join(' ').replace(/\s+/g, ' ').trim(); + if (text) { + sections.push({title, body: text}); + } + body = []; + }; + for (const line of markdown.split(/\r?\n/)) { + const heading = line.match(/^#{1,6}\s+(.+)$/); + if (heading) { + flush(); + title = heading[1]!.trim(); + continue; + } + const clean = line + .replace(/^\s*[-*+]\s+/, '') + .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') + .replace(/[*_`>#]/g, '') + .trim(); + if (clean) { + body.push(clean); + } + } + flush(); + return sections; + } + + private normalizeMaterialStats_( + raw: string, reportMarkdown: string): DreamMaterialStats { + let parsed: Record = {}; + try { + const candidate = JSON.parse(raw || '{}'); + if (candidate && typeof candidate === 'object' && + !Array.isArray(candidate)) { + parsed = candidate as Record; + } + } catch {} + const recap = parsed['recap'] && typeof parsed['recap'] === 'object' ? + parsed['recap'] as Record : {}; + const rawBuckets = + recap['time_buckets'] && typeof recap['time_buckets'] === 'object' ? + recap['time_buckets'] as Record : {}; + const measuredBuckets = + parsed['foreground_seconds_by_bucket'] && + typeof parsed['foreground_seconds_by_bucket'] === 'object' ? + parsed['foreground_seconds_by_bucket'] as Record : + null; + const hasMeasuredBuckets = measuredBuckets !== null && + ['morning', 'afternoon', 'evening', 'night'].some( + key => typeof measuredBuckets[key] === 'number'); + const bucketMinutes = (name: string, recapName: string) => { + if (hasMeasuredBuckets) { + return this.boundedNumber_( + (measuredBuckets![name] as number) / 60, 1440); + } + return this.boundedNumber_(rawBuckets[recapName], 1440); + }; + const sections = this.markdownSections_(reportMarkdown); + const themes: DreamTheme[] = []; + if (Array.isArray(recap['themes'])) { + for (const rawTheme of recap['themes']) { + if (themes.length >= 3 || !rawTheme || + typeof rawTheme !== 'object') { + continue; + } + const candidate = rawTheme as Record; + if (typeof candidate['name'] !== 'string' || + typeof candidate['summary'] !== 'string' || + !candidate['name'].trim() || !candidate['summary'].trim()) { + continue; + } + const intensity = candidate['intensity']; + themes.push({ + name: candidate['name'].trim(), + summary: candidate['summary'].trim(), + intensity: intensity === 'light' || intensity === 'deep' ? + intensity : 'medium', + timeLabel: typeof candidate['time_label'] === 'string' ? + candidate['time_label'].trim() : '', + attentionShare: + this.boundedNumber_(candidate['attention_share'], 100), + }); + } + } + if (themes.length === 0) { + sections.slice(0, 3).forEach((section, index) => themes.push({ + name: section.title || t('dream.page.theme_fallback'), + summary: section.body, + intensity: index === 0 ? 'deep' : 'medium', + timeLabel: '', + attentionShare: Math.max(36, 100 - index * 28), + })); + } + const sourceDomains = Array.isArray(parsed['source_domains']) ? + parsed['source_domains'].filter( + (item): item is string => typeof item === 'string') : []; + const recapSummary = typeof recap['summary'] === 'string' ? + recap['summary'].trim() : ''; + return { + historyDomains: this.boundedNumber_(parsed['history_domains'], 10000), + searchQueries: this.boundedNumber_(parsed['search_queries'], 10000), + conversationSessions: + this.boundedNumber_(parsed['conversation_sessions'], 10000), + sourceDomains, + recap: { + summary: recapSummary || sections[0]?.body || reportMarkdown.trim(), + timeBuckets: { + morning: bucketMinutes('morning', 'morning_minutes'), + afternoon: bucketMinutes('afternoon', 'afternoon_minutes'), + evening: bucketMinutes('evening', 'evening_minutes'), + night: bucketMinutes('night', 'night_minutes'), + }, + themes, + }, + }; + } + + private normalizeReports_(raw: unknown): DailyDreamReportData[] { if (!Array.isArray(raw)) { return []; } return raw .map(item => this.normalizeReport_(item)) - .filter((item): item is DreamReportData => item !== null); + .filter((item): item is DailyDreamReportData => item !== null); + } + + private normalizeWeeklyThread_(raw: unknown): WeeklyDreamThread|null { + if (!raw || typeof raw !== 'object') { + return null; + } + const thread = raw as Record; + if (typeof thread['title'] !== 'string' || + typeof thread['status_summary'] !== 'string' || + typeof thread['next_step'] !== 'string') { + return null; + } + return { + title: thread['title'].trim(), + statusSummary: thread['status_summary'].trim(), + nextStep: thread['next_step'].trim(), + confidence: typeof thread['confidence'] === 'number' && + Number.isFinite(thread['confidence']) ? + Math.min(Math.max(thread['confidence'], 0), 1) : 0, + sourceRefs: Array.isArray(thread['source_refs']) ? + thread['source_refs'].filter( + (ref): ref is string => typeof ref === 'string') : [], + }; + } + + private normalizeWeeklyReport_(raw: unknown): WeeklyDreamReportData|null { + if (!raw || typeof raw !== 'object') { + return null; + } + const report = raw as Record; + const content = report['content']; + if (report['reportKind'] !== 'weekly' || typeof report['id'] !== 'number' || + typeof report['weekStart'] !== 'string' || + typeof report['weekEnd'] !== 'string' || !content || + typeof content !== 'object') { + return null; + } + const value = content as Record; + const primaryThread = this.normalizeWeeklyThread_(value['primary_thread']); + if (typeof value['headline'] !== 'string' || !primaryThread) { + return null; + } + const secondaryThreads = Array.isArray(value['secondary_threads']) ? + value['secondary_threads'] + .map(thread => this.normalizeWeeklyThread_(thread)) + .filter((thread): thread is WeeklyDreamThread => thread !== null) : + []; + const retainedOutcomes: WeeklyDreamOutcome[] = []; + if (Array.isArray(value['retained_outcomes'])) { + for (const rawOutcome of value['retained_outcomes']) { + if (!rawOutcome || typeof rawOutcome !== 'object') { + continue; + } + const outcome = rawOutcome as Record; + if (typeof outcome['text'] !== 'string') { + continue; + } + retainedOutcomes.push({ + text: outcome['text'].trim(), + confidence: typeof outcome['confidence'] === 'number' && + Number.isFinite(outcome['confidence']) ? + Math.min(Math.max(outcome['confidence'], 0), 1) : 0, + sourceRefs: Array.isArray(outcome['source_refs']) ? + outcome['source_refs'].filter( + (ref): ref is string => typeof ref === 'string') : [], + }); + } + } + const footprint = value['footprint_summary'] && + typeof value['footprint_summary'] === 'object' ? + value['footprint_summary'] as Record : {}; + return { + reportKind: 'weekly', + id: report['id'], + weekStart: report['weekStart'], + weekEnd: report['weekEnd'], + headline: value['headline'].trim(), + primaryThread, + secondaryThreads, + retainedOutcomes, + footprintThemes: Array.isArray(footprint['themes']) ? + footprint['themes'].filter( + (theme): theme is string => typeof theme === 'string') : [], + timePattern: typeof footprint['time_pattern'] === 'string' ? + footprint['time_pattern'].trim() : '', + materialStats: typeof report['materialStats'] === 'string' ? + report['materialStats'] : '', + triggerKind: typeof report['triggerKind'] === 'string' ? + report['triggerKind'] : '', + sourceCount: this.boundedNumber_(report['sourceCount'], 10000), + createdAt: typeof report['createdAt'] === 'number' && + Number.isFinite(report['createdAt']) ? + report['createdAt'] : undefined, + }; + } + + private normalizeWeeklyReports_(raw: unknown): WeeklyDreamReportData[] { + if (!Array.isArray(raw)) { + return []; + } + return raw + .map(item => this.normalizeWeeklyReport_(item)) + .filter((item): item is WeeklyDreamReportData => item !== null); + } + + private reportDate_(report: DreamReportData): string { + return report.reportKind === 'daily' ? report.dreamDate : report.weekStart; + } + + private historyReports_(): DreamReportData[] { + return [...this.reports_, ...this.weeklyReports_].sort( + (a, b) => this.reportDate_(b).localeCompare(this.reportDate_(a)) || + (a.reportKind === 'daily' ? -1 : 1)); } private markReportViewed_(report: DreamReportData) { + if (report.reportKind === 'weekly') { + callNative('markWeeklyDreamReportViewed', {reportId: report.id}) + .catch(() => {}); + return; + } callNativeArgs('markDreamReportViewed', report.id).catch(() => {}); } private selectHistoryReport_(report: DreamReportData) { this.report_ = report; - this.habitStates_ = this.loadPersistedHabitStates_(report); + this.habitStates_ = report.reportKind === 'daily' ? + this.loadPersistedHabitStates_(report) : {}; this.dreamExclusionError_ = ''; this.markReportViewed_(report); } @@ -674,7 +1787,7 @@ export class DaoDreamApp extends CrLitElement { } } - private sourceDomainsForReport_(report: DreamReportData): string[] { + private sourceDomainsForReport_(report: DailyDreamReportData): string[] { try { const stats = JSON.parse(report.materialStats || '{}') as { source_domains?: unknown; @@ -738,37 +1851,28 @@ export class DaoDreamApp extends CrLitElement { } private rejectHabit_(habit: DreamHabit, index: number) { - callNative('getPreferences') - .then((prefs) => { - const list = prefs as Array<{id: number; key: string}>; - const match = list.find((p) => p.key === habit.key); - if (match) { - return callNativeArgs('deleteMemory', 'preference', match.id); - } - return undefined; - }) - .catch(() => {}); this.persistHabitState_(habit, 'rejected'); this.habitStates_ = {...this.habitStates_, [index]: 'rejected'}; } - private loadPersistedHabitStates_(report: DreamReportData): + private loadPersistedHabitStates_(report: DailyDreamReportData): Record { const stored = this.readHabitFeedbackStore_(); const states: Record = {}; - report.habits - .filter((h) => h.relation !== 'reinforce') - .forEach((habit, index) => { + report.habits.forEach((habit, index) => { + if (habit.relation === 'reinforce') { + return; + } const state = stored[this.habitFeedbackKey_(report, habit)]; if (state === 'confirmed' || state === 'rejected') { states[index] = state; } - }); + }); return states; } private persistHabitState_(habit: DreamHabit, state: HabitState) { - if (!this.report_) { + if (!this.report_ || this.report_.reportKind !== 'daily') { return; } const stored = this.readHabitFeedbackStore_(); @@ -798,7 +1902,7 @@ export class DaoDreamApp extends CrLitElement { } } - private habitFeedbackKey_(report: DreamReportData, habit: DreamHabit) { + private habitFeedbackKey_(report: DailyDreamReportData, habit: DreamHabit) { return `${report.dreamDate}\u0000${habit.key}\u0000${habit.value}`; } @@ -809,17 +1913,27 @@ export class DaoDreamApp extends CrLitElement { return html`${container}`; } - private renderHabits_(report: DreamReportData) { - const rows = report.habits.filter((h) => h.relation !== 'reinforce'); + private renderHabits_(report: DailyDreamReportData) { + const rows = report.habits; if (rows.length === 0) { return nothing; } return html` -
-

${t('chat.dream.habits_title')}

+
+
+

${t('dream.page.memory_title')}

+ ${t('dream.page.memory_hint')} +
+
${rows.map((habit, index) => html`
+ + ${habit.relation === 'new' ? t('dream.page.memory_new') : + habit.relation === 'reinforce' ? + t('dream.page.memory_reinforce') : + t('dream.page.memory_contradict')} +
${habit.relation === 'contradict' ? t('chat.dream.contradict_prefix') + ' ' : ''}${habit.value} @@ -829,40 +1943,319 @@ export class DaoDreamApp extends CrLitElement { nothing}
- ${this.habitStates_[index] === 'confirmed' ? + ${habit.relation === 'reinforce' ? html` + + ${t('dream.page.memory_existing')}` : + this.habitStates_[index] === 'confirmed' ? html` ${t('chat.dream.habit_confirmed')}` : this.habitStates_[index] === 'rejected' ? html` ${t('chat.dream.habit_rejected')}` : html` - - `} + + `}
`)} +
+
`; + } + + private parseDreamDate_(value: string): Date|null { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + const date = new Date( + Number(match[1]), Number(match[2]) - 1, Number(match[3])); + return Number.isFinite(date.getTime()) ? date : null; + } + + private formatDreamDate_(value: string): string { + const date = this.parseDreamDate_(value); + return date ? new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + weekday: 'short', + }).format(date) : value; + } + + private reportActivityLevel_(report: DailyDreamReportData): number { + const buckets = report.stats.recap.timeBuckets; + const focusedMinutes = + buckets.morning + buckets.afternoon + buckets.evening + buckets.night; + if (focusedMinutes > 0) { + return focusedMinutes >= 180 ? 4 : focusedMinutes >= 90 ? 3 : + focusedMinutes >= 30 ? 2 : 1; + } + const signals = report.stats.historyDomains + report.stats.searchQueries + + report.stats.conversationSessions; + return signals >= 16 ? 4 : signals >= 9 ? 3 : signals >= 3 ? 2 : 1; + } + + private renderActivityHeatmap_() { + const reportsByDate = new Map( + this.reports_.map(report => [report.dreamDate, report])); + const latestDate = this.parseDreamDate_(this.reports_[0]?.dreamDate || '') || + new Date(); + const endDay = new Date( + latestDate.getFullYear(), latestDate.getMonth(), latestDate.getDate()); + const mondayOffset = (endDay.getDay() + 6) % 7; + const startDay = new Date(endDay); + startDay.setDate(endDay.getDate() - mondayOffset - 52 * 7); + const cells = []; + const monthLabels: Array<{label: string; column: number}> = []; + let previousMonth = -1; + for (let index = 0; index < 53 * 7; index++) { + const date = new Date(startDay); + date.setDate(startDay.getDate() + index); + if (date > endDay) { + break; + } + const dateKey = [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + ].join('-'); + const report = reportsByDate.get(dateKey); + const level = report ? this.reportActivityLevel_(report) : 0; + const column = Math.floor(index / 7) + 1; + if (date.getMonth() !== previousMonth && date.getDate() <= 7) { + monthLabels.push({ + label: new Intl.DateTimeFormat(undefined, {month: 'short'}) + .format(date), + column, + }); + previousMonth = date.getMonth(); + } + const label = this.formatDreamDate_(dateKey); + cells.push(html` + `); + } + return html` +
+
+

${t('dream.page.activity_title')}

+ ${t('dream.page.activity_reports', { + count: this.reports_.length, + })} +
+
+
+ ${monthLabels.map(month => html` + ${month.label}`)} +
+
+ ${cells} +
+
+
+ ${t('dream.page.activity_less')} + ${[0, 1, 2, 3, 4].map(level => html` + `)} + ${t('dream.page.activity_more')} +
+
`; + } + + private renderThemeIcon_(index: number) { + if (index % 3 === 1) { + return html` + `; + } + if (index % 3 === 2) { + return html` + `; + } + return html` + `; + } + + private formatMinutes_(minutes: number) { + return t('dream.page.minutes', {count: minutes}); + } + + private renderRhythm_(report: DailyDreamReportData) { + const buckets = report.stats.recap.timeBuckets; + const slots = [ + [t('dream.page.rhythm_morning'), buckets.morning], + [t('dream.page.rhythm_afternoon'), buckets.afternoon], + [t('dream.page.rhythm_evening'), buckets.evening], + [t('dream.page.rhythm_night'), buckets.night], + ] as Array<[string, number]>; + const peak = Math.max(...slots.map(([, value]) => value), 1); + const peakIndex = slots.reduce( + (best, slot, index) => slot[1] > slots[best]![1] ? index : best, 0); + if (slots.every(([, value]) => value === 0)) { + return nothing; + } + return html` +
+
+

${t('dream.page.rhythm_title')}

+ ${t('dream.page.rhythm_hint')} +
+
+ ${slots.map(([label, value], index) => html` +
+
+
+ ${label} + ${this.formatMinutes_(value)} +
+
`)} +
+
`; + } + + private renderThemes_(report: DailyDreamReportData) { + const themes = report.stats.recap.themes; + if (themes.length === 0) { + return nothing; + } + return html` +
+
+

${t('dream.page.themes_title')}

+ ${t('dream.page.themes_count', {count: themes.length})} +
+
+ ${themes.map((theme, index) => html` +
+
+ ${this.renderThemeIcon_(index)} + ${theme.name} +
+

${theme.summary}

+ +
`)} +
+
`; + } + + private renderStats_(report: DailyDreamReportData) { + const stats = report.stats; + return html` +
+ ${[ + [stats.historyDomains, t('dream.page.stats_domains')], + [stats.searchQueries, t('dream.page.stats_searches')], + [stats.conversationSessions, t('dream.page.stats_conversations')], + ].map(([value, label]) => html` +
+ ${value} + ${label} +
`)}
`; } private renderHistoryList_() { + const reports = this.historyReports_(); + const selectedKey = this.report_ ? + `${this.report_.reportKind}:${this.report_.id}` : ''; return html` - + `; } - private renderDreamExclusionShortcut_(report: DreamReportData) { + private renderDreamExclusionShortcut_(report: DailyDreamReportData) { const domains = this.sourceDomainsForReport_(report); return html`
@@ -923,7 +2316,7 @@ export class DaoDreamApp extends CrLitElement { `; } - private renderRerunReportButton_(report: DreamReportData) { + private renderRerunReportButton_(report: DailyDreamReportData) { const label = t('dream.page.rerun_report'); const icon = html` 0) { + lines.push('', `## ${t('dream.page.weekly_secondary_threads')}`); + for (const thread of report.secondaryThreads) { + lines.push('', `### ${thread.title}`, thread.statusSummary, + `**${t('dream.page.weekly_next_step')}** ${thread.nextStep}`); + } + } + if (report.retainedOutcomes.length > 0) { + lines.push('', `## ${t('dream.page.weekly_outcomes')}`, + ...report.retainedOutcomes.map(outcome => `- ${outcome.text}`)); + } + if (report.timePattern) { + lines.push('', `## ${t('dream.page.weekly_time_pattern')}`, + report.timePattern); + } + return lines.join('\n'); + } + + private renderWeeklyThread_(thread: WeeklyDreamThread, primary: boolean) { return html` -
-
- ${this.renderMarkdown_(report.reportMarkdown)} +
+
+ ${this.renderThemeIcon_(primary ? 0 : 1)} + ${thread.title} +
+

${thread.statusSummary}

+
+ ${t('dream.page.weekly_next_step')} +

${thread.nextStep}

+
`; + } + + private renderWeeklyReportArticle_(report: WeeklyDreamReportData) { + const generatedAt = this.formatGeneratedAt_(report.createdAt); + return html` +
+
+
+
${t('dream.page.weekly_eyebrow')}
+

${report.headline}

+
+
+
${t('dream.page.weekly_period', { + start: report.weekStart, + end: report.weekEnd, + })}
+ ${this.renderCopyImageButton_()} +
+
+
+
${t('dream.page.weekly_primary_thread')}
+

${report.primaryThread.statusSummary}

+
+
+
+

${t('dream.page.weekly_threads')}

+ ${t('dream.page.themes_count', { + count: 1 + report.secondaryThreads.length, + })} +
+
+ ${this.renderWeeklyThread_(report.primaryThread, true)} + ${report.secondaryThreads.map( + thread => this.renderWeeklyThread_(thread, false))} +
+
+ ${report.retainedOutcomes.length > 0 ? html` +
+
+

${t('dream.page.weekly_outcomes')}

+ ${t('dream.page.weekly_sources', { + count: report.sourceCount, + })} +
+
+ ${report.retainedOutcomes.map(outcome => html` +
+
${outcome.text}
+
`)} +
+
` : nothing} + ${report.timePattern || report.footprintThemes.length > 0 ? html` +
+
+

${t('dream.page.weekly_time_pattern')}

+ ${report.footprintThemes.join(' · ')} +
+ ${report.timePattern ? html`

${report.timePattern}

` : nothing} +
` : nothing} +
+ + + ${t('dream.page.full_report')} + +
+ ${this.renderMarkdown_(this.weeklyReportMarkdown_(report))} +
+
+ ${generatedAt ? html`
+ ${t('dream.debug.generated_at', {time: generatedAt})} +
` : nothing} +
`; + } + + private renderReportArticle_(report: DailyDreamReportData) { + const generatedAt = this.formatGeneratedAt_(report.createdAt); + return html` +
+
+
+
+ + ${t('dream.page.recap_eyebrow')} +
+

${t('dream.page.recap_title')}

+
+
+
${this.formatDreamDate_(report.dreamDate)}
+ ${this.renderRerunReportButton_(report)} + ${this.renderCopyImageButton_()} +
+
+
+
${t('dream.page.summary_label')}
+

${report.stats.recap.summary}

+
+ ${this.renderRhythm_(report)} + ${this.renderThemes_(report)} + ${this.renderStats_(report)} ${this.renderHabits_(report)} - ${this.renderDreamExclusionShortcut_(report)} - ${report.debugMaterialJson ? html` -
- ${t('chat.dream.debug_title')} - ${generatedAt ? html` -
- ${t('dream.debug.generated_at', {time: generatedAt})} -
` : nothing} -
${report.debugMaterialJson}
-
` : nothing} +
+ + + ${t('dream.page.full_report')} + +
+ ${this.renderMarkdown_(report.reportMarkdown)} +
+
+
+ ${this.renderDreamExclusionShortcut_(report)} + ${report.debugMaterialJson ? html` +
+ ${t('chat.dream.debug_title')} + ${generatedAt ? html` +
+ ${t('dream.debug.generated_at', {time: generatedAt})} +
` : nothing} +
${report.debugMaterialJson}
+
` : nothing} +
`; } @@ -1100,20 +2656,6 @@ export class DaoDreamApp extends CrLitElement { const isHistory = this.currentRoute_() === 'history'; return html`
-
-
-
${t('dream.page.eyebrow')}
-

${t('dream.page.title')}

-
- ${report ? html` -
-
- ${t('chat.dream.card_date', {date: report.dreamDate})} -
- ${this.renderRerunReportButton_(report)} - ${this.renderCopyImageButton_()} -
` : nothing} -
${this.loading_ ? html`
${t('dream.page.loading')}
` : @@ -1134,9 +2676,13 @@ export class DaoDreamApp extends CrLitElement { ${isHistory ? html`
${this.renderHistoryList_()} - ${this.renderReportArticle_(report)} + ${report.reportKind === 'daily' ? + this.renderReportArticle_(report) : + this.renderWeeklyReportArticle_(report)}
` : - this.renderReportArticle_(report)}` + report.reportKind === 'daily' ? + this.renderReportArticle_(report) : + this.renderWeeklyReportArticle_(report)}` }
`; diff --git a/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts b/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts index fd093f0b..e2a1dd6f 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts @@ -20,8 +20,28 @@ interface DreamHabit { relation: 'new'|'reinforce'|'contradict'; } +interface DreamTheme { + name: string; + summary: string; + intensity: 'light'|'medium'|'deep'; + time_label: string; + attention_share: number; +} + +interface DreamRecap { + summary: string; + time_buckets: { + morning_minutes: number; + afternoon_minutes: number; + evening_minutes: number; + night_minutes: number; + }; + themes: DreamTheme[]; +} + interface DreamResult { report_markdown: string; + recap: DreamRecap; habits: DreamHabit[]; scenario_adjustments: Array<{scenario_id: string; suggestion: string}>; } @@ -38,7 +58,8 @@ export interface DreamCallResult { const SYSTEM_PROMPT = `You are Dao Browser's dream analyst. You receive a condensed JSON "material pack" describing one browsing day: top domains with page titles, foreground_seconds, total_seconds, duration_level, and -time-of-day buckets, search queries, short excerpts of the user's questions to +time-of-day visit buckets plus foreground_seconds_by_bucket, search queries, +short excerpts of the user's questions to the in-browser AI agent, high-confidence known preferences, feedback stats on proactive suggestions, and aggregate counts. Turn it into a calm morning reflection and a small set of durable memory candidates. The report should @@ -53,6 +74,22 @@ shape: 1-3 gentle reflection questions or next-day hints. Do not list every domain, page title, or query. Friendly, calm tone; address the user directly in the target language.>", + "recap": { + "summary": "", + "time_buckets": { + "morning_minutes": , + "afternoon_minutes": , + "evening_minutes": , + "night_minutes": + }, + "themes": [{ + "name": "", + "summary": "", + "intensity": "light" | "medium" | "deep", + "time_label": "", + "attention_share": + }] + }, "habits": [{ "key": "", "value": "", @@ -82,6 +119,13 @@ Rules: duration_level "deep" deserves the most narrative weight, "medium" can be mentioned briefly, and "light" should be summarized in passing or omitted unless it reinforces searches, agent conversations, or another strong theme. +- Build recap.time_buckets from stats.foreground_seconds_by_bucket, converting + measured seconds to rounded minutes. Keep zero values when a bucket has no + foreground activity. Do not infer time from the visit-count-only buckets. +- Include 0-3 recap themes. Merge related domains, titles, searches, and agent + questions into user-meaningful topics instead of using domain names as theme + names. Set the strongest theme's attention_share to 100 and scale the rest + relative to it. - Treat material.preferences as existing memory, not as new evidence from the day. Use it only to decide whether a habit candidate is "reinforce", "contradict", or "new". @@ -111,6 +155,7 @@ function validateResult(parsed: unknown): DreamResult|null { obj['report_markdown'].length === 0) { return null; } + const recap = normalizeRecap(obj['recap']); const habits: DreamHabit[] = []; if (Array.isArray(obj['habits'])) { for (const h of obj['habits']) { @@ -150,11 +195,64 @@ function validateResult(parsed: unknown): DreamResult|null { } return { report_markdown: obj['report_markdown'], + recap, habits, scenario_adjustments: adjustments, }; } +function boundedInteger(value: unknown, max: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return Math.min(Math.max(Math.round(value), 0), max); +} + +function normalizeRecap(value: unknown): DreamRecap { + const recap = typeof value === 'object' && value !== null ? + value as Record : {}; + const rawBuckets = + typeof recap['time_buckets'] === 'object' && + recap['time_buckets'] !== null ? + recap['time_buckets'] as Record : {}; + const themes: DreamTheme[] = []; + if (Array.isArray(recap['themes'])) { + for (const theme of recap['themes']) { + if (themes.length >= 3 || typeof theme !== 'object' || theme === null) { + continue; + } + const candidate = theme as Record; + if (typeof candidate['name'] !== 'string' || + typeof candidate['summary'] !== 'string' || + !candidate['name'].trim() || !candidate['summary'].trim()) { + continue; + } + const intensity = candidate['intensity']; + themes.push({ + name: candidate['name'].trim(), + summary: candidate['summary'].trim(), + intensity: intensity === 'light' || intensity === 'deep' ? + intensity : 'medium', + time_label: typeof candidate['time_label'] === 'string' ? + candidate['time_label'].trim() : '', + attention_share: boundedInteger(candidate['attention_share'], 100), + }); + } + } + return { + summary: typeof recap['summary'] === 'string' ? + recap['summary'].trim() : '', + time_buckets: { + morning_minutes: boundedInteger(rawBuckets['morning_minutes'], 1440), + afternoon_minutes: + boundedInteger(rawBuckets['afternoon_minutes'], 1440), + evening_minutes: boundedInteger(rawBuckets['evening_minutes'], 1440), + night_minutes: boundedInteger(rawBuckets['night_minutes'], 1440), + }, + themes, + }; +} + // Strips ```json fences if the model wrapped its output anyway. export function extractJson(raw: string): string { const trimmed = raw.trim(); diff --git a/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts b/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts index c5011b6f..71ba887f 100644 --- a/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts +++ b/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts @@ -373,6 +373,49 @@ const dict: Dictionary = { 'Run Dream Analysis from Agent settings to generate a report.', 'dream.page.error': 'Failed to load dream report: {error}', 'dream.page.history_title': 'History', + 'dream.page.history_recent': 'Recent {count} days', + 'dream.page.recap_eyebrow': 'Yesterday\'s Dream Recap', + 'dream.page.recap_title': 'How was your day yesterday?', + 'dream.page.summary_label': 'In one sentence', + 'dream.page.rhythm_title': 'Your rhythm', + 'dream.page.rhythm_hint': 'By foreground focus time', + 'dream.page.rhythm_morning': 'Morning', + 'dream.page.rhythm_afternoon': 'Afternoon', + 'dream.page.rhythm_evening': 'Evening', + 'dream.page.rhythm_night': 'Late night', + 'dream.page.minutes': '{count} min', + 'dream.page.themes_title': 'What you did', + 'dream.page.themes_count': '{count} themes', + 'dream.page.theme_light': 'Light', + 'dream.page.theme_medium': 'Focused', + 'dream.page.theme_deep': 'Deep', + 'dream.page.theme_fallback': 'Daily focus', + 'dream.page.stats_label': 'Daily activity totals', + 'dream.page.stats_domains': 'domains visited', + 'dream.page.stats_searches': 'searches', + 'dream.page.stats_conversations': 'AI conversations', + 'dream.page.memory_title': 'Remember these?', + 'dream.page.memory_hint': 'Confirmed items become long-term memory', + 'dream.page.memory_new': 'New', + 'dream.page.memory_reinforce': 'Confirmed', + 'dream.page.memory_contradict': 'Changed', + 'dream.page.memory_existing': 'Already in memory', + 'dream.page.full_report': 'Expand full written recap', + 'dream.page.activity_title': 'Past year activity', + 'dream.page.activity_reports': '{count} reports', + 'dream.page.activity_less': 'Less', + 'dream.page.activity_more': 'More', + 'dream.page.activity_label': 'Daily Dream report activity', + 'dream.page.weekly_badge': 'Weekly', + 'dream.page.weekly_eyebrow': 'Weekly Dream Recap', + 'dream.page.weekly_period': '{start} – {end}', + 'dream.page.weekly_primary_thread': 'Primary thread', + 'dream.page.weekly_threads': 'Threads to continue', + 'dream.page.weekly_secondary_threads': 'Supporting threads', + 'dream.page.weekly_next_step': 'Next step', + 'dream.page.weekly_outcomes': 'Retained outcomes', + 'dream.page.weekly_time_pattern': 'Weekly footprint', + 'dream.page.weekly_sources': '{count} sources', 'dream.page.copy_image': 'Copy image', 'dream.page.copy_image_copied': 'Copied image', 'dream.page.copy_image_failed': 'Copy failed', diff --git a/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts b/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts index cf4dd7f0..3a7fc852 100644 --- a/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts +++ b/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts @@ -273,6 +273,49 @@ const dict: Dictionary = { 'dream.page.empty_desc': '可以在 Agent 设置里运行一次梦境分析来生成报告。', 'dream.page.error': '梦境报告加载失败:{error}', 'dream.page.history_title': '历史分析', + 'dream.page.history_recent': '近 {count} 天', + 'dream.page.recap_eyebrow': '昨日梦境回顾', + 'dream.page.recap_title': '你昨天过得怎么样', + 'dream.page.summary_label': '一句话总结', + 'dream.page.rhythm_title': '今日节奏', + 'dream.page.rhythm_hint': '按前台专注时长', + 'dream.page.rhythm_morning': '上午', + 'dream.page.rhythm_afternoon': '下午', + 'dream.page.rhythm_evening': '晚上', + 'dream.page.rhythm_night': '深夜', + 'dream.page.minutes': '{count} 分钟', + 'dream.page.themes_title': '你在做什么', + 'dream.page.themes_count': '{count} 个主题', + 'dream.page.theme_light': '轻量', + 'dream.page.theme_medium': '专注', + 'dream.page.theme_deep': '深度', + 'dream.page.theme_fallback': '今日重点', + 'dream.page.stats_label': '今日活动统计', + 'dream.page.stats_domains': '个访问域名', + 'dream.page.stats_searches': '次搜索', + 'dream.page.stats_conversations': '段 AI 对话', + 'dream.page.memory_title': '要记住这些吗', + 'dream.page.memory_hint': '确认后写入长期记忆', + 'dream.page.memory_new': '新发现', + 'dream.page.memory_reinforce': '已印证', + 'dream.page.memory_contradict': '有变化', + 'dream.page.memory_existing': '已在记忆中', + 'dream.page.full_report': '展开完整文字回顾', + 'dream.page.activity_title': '过去一年的活跃度', + 'dream.page.activity_reports': '{count} 份报告', + 'dream.page.activity_less': '少', + 'dream.page.activity_more': '多', + 'dream.page.activity_label': '过去一年每日梦境报告活跃度', + 'dream.page.weekly_badge': '每周', + 'dream.page.weekly_eyebrow': '每周梦境回顾', + 'dream.page.weekly_period': '{start} 至 {end}', + 'dream.page.weekly_primary_thread': '本周主线', + 'dream.page.weekly_threads': '值得继续的主线', + 'dream.page.weekly_secondary_threads': '辅助主线', + 'dream.page.weekly_next_step': '下一步', + 'dream.page.weekly_outcomes': '本周成果', + 'dream.page.weekly_time_pattern': '本周足迹', + 'dream.page.weekly_sources': '{count} 个来源', 'dream.page.copy_image': '复制图片', 'dream.page.copy_image_copied': '已复制图片', 'dream.page.copy_image_failed': '复制失败',